mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 17:24:30 +00:00
Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b3431db6b | ||
|
|
502de02840 | ||
|
|
bde25c0bc8 | ||
|
|
c7aa5a10b0 | ||
|
|
fa2d12222a | ||
|
|
73c5ab149e | ||
|
|
a65d8cfa13 | ||
|
|
a86abadb14 | ||
|
|
41e907fa52 | ||
|
|
2c1c6f7eac | ||
|
|
b6169af6aa | ||
|
|
a3f1bceb15 | ||
|
|
c2153b277b | ||
|
|
2ffeeaca0b | ||
|
|
14eaad09e9 | ||
|
|
6ab1ed95c9 | ||
|
|
7c873f5cc2 | ||
|
|
68e29a8b6e | ||
|
|
d2b2a7e9f7 | ||
|
|
b6442dda75 | ||
|
|
336132e049 | ||
|
|
a2f43009f7 | ||
|
|
d27cbe78ba | ||
|
|
803bd0cdf3 | ||
|
|
9456790083 | ||
|
|
425f72b33f | ||
|
|
286c59707b | ||
|
|
97a941c45d | ||
|
|
9d6fdfe232 | ||
|
|
005270608b | ||
|
|
be9e965001 | ||
|
|
64a499975e | ||
|
|
8c7cde5d5c | ||
|
|
df2d660d1e | ||
|
|
f5c9c34291 | ||
|
|
7ca736d525 | ||
|
|
8e953e8bc4 | ||
|
|
7ef52cdd66 | ||
|
|
55060c4f67 | ||
|
|
7564934d06 | ||
|
|
28b0319e27 | ||
|
|
4b6cbe8b11 | ||
|
|
c7497c758d | ||
|
|
0a8c8945ea | ||
|
|
2e4383524f | ||
|
|
13db718bda | ||
|
|
727b8c8f24 | ||
|
|
15fabb62aa | ||
|
|
0d28886d46 | ||
|
|
fee85a14e7 | ||
|
|
80caf78306 | ||
|
|
d20b33348b | ||
|
|
7ff9261000 | ||
|
|
11c0f0483f | ||
|
|
74cd08f765 | ||
|
|
7c15cdda66 | ||
|
|
f6d8e241eb | ||
|
|
e9d95ad190 | ||
|
|
a47accd894 | ||
|
|
77e5a4982b | ||
|
|
feaaa5cd81 | ||
|
|
58e7407c38 | ||
|
|
22d299959f | ||
|
|
5e78f2f7f7 | ||
|
|
9b4bef3fd1 | ||
|
|
3e2703d8e1 | ||
|
|
d409d55615 | ||
|
|
4214ef365e | ||
|
|
80fc371f11 | ||
|
|
95b0786725 | ||
|
|
749af31cc3 | ||
|
|
991eadeff6 | ||
|
|
56419dc789 | ||
|
|
95cb51b2ad | ||
|
|
59aa70ce5a | ||
|
|
1e9741043b | ||
|
|
77547af110 | ||
|
|
5239665e2b | ||
|
|
fd840011d2 |
@@ -1,5 +1,9 @@
|
||||
name: Nightly
|
||||
|
||||
# Trunk-based: nightly always builds main — the release-branch discovery
|
||||
# from the old release-centric flow is gone (it pinned nightlies to the
|
||||
# highest release/v* branch forever, even after it shipped). Stabilization
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
@@ -9,33 +13,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
date: ${{ steps.date.outputs.date }}
|
||||
|
||||
steps:
|
||||
- name: Find active release branch
|
||||
id: branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
|
||||
--jq '[.[].ref | ltrimstr("refs/heads/")] | map(ltrimstr("refs/heads/")) | .[]' \
|
||||
| sort -V | tail -1 || true)
|
||||
if [[ -z "$branch" ]]; then
|
||||
branch="main"
|
||||
fi
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
echo "Active branch: $branch"
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-docker:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -44,9 +22,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -65,6 +46,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/got-feedback/feedback:nightly
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: rc
|
||||
|
||||
# Release-candidate images for stabilization: every push to a release/**
|
||||
# branch builds and pushes ghcr.io tags :rc (moving) and
|
||||
# :rc-<version>-<date> (pinned). Final versioned images still come from
|
||||
# release.yml on tag push.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One build per branch at a time; a newer push supersedes an in-flight one.
|
||||
concurrency:
|
||||
group: rc-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Derive RC tags
|
||||
id: meta
|
||||
run: |
|
||||
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
|
||||
version="${GITHUB_REF_NAME#release/}"
|
||||
version="${version#v}"
|
||||
date="$(date -u +%Y%m%d)"
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/got-feedback/feedback:rc"
|
||||
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
# stable releases (no pre-release suffix).
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
|
||||
fi
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -8,6 +8,11 @@ name: ship-ci
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, 'release/**']
|
||||
# Trunk-based: post-merge CI on main catches semantic conflicts between
|
||||
# independently-green PRs; push on release/** covers stabilization
|
||||
# cherry-picks that land without a PR.
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -8,6 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
### Added
|
||||
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()` → `{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
|
||||
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
|
||||
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
|
||||
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
|
||||
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (0–1, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
|
||||
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
||||
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 0–1 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
|
||||
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
||||
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
|
||||
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
|
||||
@@ -54,10 +75,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||
|
||||
### Fixed
|
||||
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
|
||||
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
|
||||
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
|
||||
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport` → `{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
|
||||
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
|
||||
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two render with different idle colours — grid `text-white`, List View `text-fb-textDim` — and the handler only ever removed the grid's `text-white`. So in List View the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also writes the new state back onto the in-memory song model so a re-render / virtualized-grid recycle agrees instead of reverting. Tests: `tests/js/v3_favorites_toggle.test.js`.
|
||||
- **v3 Songs A–Z rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps** — `pointerdown` calls `setPointerCapture`, after which the browser **retargets the follow-up `click` to the rail container**, so the click handler's `closest('.v3-azrail-letter')` resolved `null` and a plain tap (no `pointermove`) had no other path → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); they now scale with the viewport (`clamp(.72rem, 1.4vh, 1.05rem)`), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
|
||||
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
|
||||
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
|
||||
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
|
||||
|
||||
@@ -231,6 +231,18 @@ window.feedBackViz_my_viz = function () {
|
||||
// toneChanges, toneBase, mastery, hasPhraseData, inverted,
|
||||
// lefty, renderScale, lyricsVisible, the 2D coordinate
|
||||
// helpers project and fretX, and getNoteState (see below).
|
||||
// The bundle OBJECT is reused across frames (mutated in
|
||||
// place — no per-frame allocation): never cache it or
|
||||
// compare its identity between frames; field values are
|
||||
// only valid for the current draw call. Array FIELDS still
|
||||
// swap reference when chart data changes, so field-identity
|
||||
// caches (`myRef !== bundle.chords`) remain valid.
|
||||
// Windowed-iteration helpers (stable fn refs): bundle
|
||||
// .lowerBoundT(arr, time) is a lower-bound binary search on
|
||||
// `.t` (notes/chords); bundle.lowerBoundTime(arr, time) on
|
||||
// `.time` (beats/anchors/sections). Use these to cull to
|
||||
// the visible window instead of full-scanning chart arrays
|
||||
// per frame.
|
||||
// `stringCount` is the active arrangement's string count (4
|
||||
// for bass, 6 for guitar, 7+ for extended-range GP imports —
|
||||
// size string-indexed geometry against this, not a hardcoded
|
||||
|
||||
+8
-8
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
|
||||
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
||||
FROM alpine:3.20 AS ffmpeg-fetcher
|
||||
ARG TARGETARCH
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
|
||||
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
|
||||
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
|
||||
RUN apk add --no-cache curl xz \
|
||||
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
||||
&& case "$arch" in \
|
||||
@@ -94,9 +94,9 @@ FROM python:3.12-slim
|
||||
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
|
||||
# ARG values don't cross stage boundaries in multi-stage builds; defaults
|
||||
# must be repeated here to take effect when no --build-arg is supplied.
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
|
||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||
# similar). Done first so any subsequent installs resolve against the
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# fee[dB]ack
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Description | Install |
|
||||
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
|
||||
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
|
||||
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
|
||||
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
|
||||
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
|
||||
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
|
||||
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
|
||||
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
|
||||
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
|
||||
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
|
||||
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
|
||||
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
||||
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
|
||||
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
||||
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
|
||||
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
|
||||
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
|
||||
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
||||
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
||||
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
|
||||
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
|
||||
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
||||
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
|
||||
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
|
||||
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
||||
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
||||
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
||||
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
||||
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
|
||||
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
||||
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||
|
||||
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
||||
|
||||
```bash
|
||||
cd plugins
|
||||
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
|
||||
docker compose restart
|
||||
```
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
|
||||
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
|
||||
)
|
||||
_SONG_FILENAME_RE = re.compile(
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
+232
-143
@@ -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])
|
||||
@@ -229,12 +237,29 @@ def _build_tempo_map(root: ET.Element) -> list[tuple[int, float]]:
|
||||
return events
|
||||
|
||||
|
||||
def _parse_tuning(el: ET.Element) -> list[int]:
|
||||
"""Return the string-tuning MIDI pitches from the first ``Tuning`` Property
|
||||
at or below ``el`` (a Track or a single Staff), high string first. ``[]`` if
|
||||
there is no Tuning property or its Pitches text is unparseable."""
|
||||
for prop in el.findall('.//Property'):
|
||||
if prop.get('name') == 'Tuning':
|
||||
pe = prop.find('Pitches')
|
||||
if pe is not None and pe.text:
|
||||
try:
|
||||
return [int(p) for p in pe.text.split()]
|
||||
except ValueError:
|
||||
return []
|
||||
break
|
||||
return []
|
||||
|
||||
|
||||
def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
"""Return a list of raw track dicts from the GPIF Tracks element."""
|
||||
# Lookups for per-track note counting. MasterBar/Bars lists one bar id per
|
||||
# track in raw Tracks order, so the enumerate index below (which counts
|
||||
# skipped pseudo-tracks) is the correct bar-lookup index — same mapping
|
||||
# convert_file uses via filtered_to_raw.
|
||||
# *stave* (not per Track element) in document order. A multi-stave track
|
||||
# (e.g. GP8 piano with treble + bass) occupies N consecutive columns; the
|
||||
# bar_column counter below advances by num_staves per track so every track
|
||||
# gets the correct column regardless of neighbour stave counts.
|
||||
_masterbars = list(root.find('MasterBars') or [])
|
||||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||||
@@ -279,10 +304,17 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
return n
|
||||
|
||||
result = []
|
||||
for raw_idx, t in enumerate(root.find('Tracks') or []):
|
||||
bar_column = 0
|
||||
for t in (root.find('Tracks') or []):
|
||||
# Count staves: each Staff occupies one column in MasterBar/Bars.
|
||||
# Default to 1 for tracks with no explicit <Staves> (GP3/4/5, old GPX).
|
||||
num_staves = max(1, len(list(t.findall('Staves/Staff'))))
|
||||
stave_columns = list(range(bar_column, bar_column + num_staves))
|
||||
|
||||
name = (t.findtext('Name') or '').strip()
|
||||
if name.startswith('@$') and name.endswith('$@'):
|
||||
continue # GP internal pseudo-tracks (raw_idx still advances)
|
||||
bar_column += num_staves
|
||||
continue # GP internal pseudo-tracks (bar_column still advances)
|
||||
|
||||
gm = t.find('GeneralMidi')
|
||||
midi_program = 0
|
||||
@@ -319,27 +351,37 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# String tuning
|
||||
string_pitches: list[int] = []
|
||||
for prop in t.findall('.//Property'):
|
||||
if prop.get('name') == 'Tuning':
|
||||
pe = prop.find('Pitches')
|
||||
if pe is not None and pe.text:
|
||||
try:
|
||||
string_pitches = [int(p) for p in pe.text.split()]
|
||||
except ValueError:
|
||||
pass
|
||||
# String tuning — one list per stave, in stave order. Reading all
|
||||
# `.//Property` descendants across every stave meant the last stave's
|
||||
# tuning overwrote the first; for a GP8 piano (treble 6-string +
|
||||
# bass 5-string) that caused stave-0 notes with String=5 to be
|
||||
# out-of-range against the 5-entry bass tuning and silently dropped.
|
||||
# A staff with no Tuning of its own falls back to the track-level
|
||||
# property (never to []) — an empty list silently drops every fretted
|
||||
# note on that stave in `_note_midi`. The list stays parallel to
|
||||
# `stave_columns` so a per-stave column always has a matching tuning.
|
||||
_track_tuning = _parse_tuning(t)
|
||||
_staff_els = list(t.findall('Staves/Staff'))
|
||||
if _staff_els:
|
||||
stave_pitches = [(_parse_tuning(s) or _track_tuning) for s in _staff_els]
|
||||
else:
|
||||
# No <Staves> (GP3/4/5 or old GPX): single track-level tuning.
|
||||
stave_pitches = [_track_tuning]
|
||||
|
||||
result.append({
|
||||
'_el': t,
|
||||
'id': t.get('id', ''),
|
||||
'name': name,
|
||||
'string_pitches': string_pitches,
|
||||
'string_pitches': stave_pitches[0], # primary stave (existing key)
|
||||
'num_staves': num_staves,
|
||||
'stave_columns': stave_columns,
|
||||
'stave_pitches': stave_pitches,
|
||||
'is_drums': is_drums,
|
||||
'midi_program': midi_program,
|
||||
'midi_channel': midi_channel,
|
||||
'note_count': _note_count_for_raw(raw_idx),
|
||||
'note_count': sum(_note_count_for_raw(c) for c in stave_columns),
|
||||
})
|
||||
bar_column += num_staves
|
||||
return result
|
||||
|
||||
|
||||
@@ -367,6 +409,121 @@ def _beat_dur_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) ->
|
||||
return dur_qn * (60.0 / tempo_bpm)
|
||||
|
||||
|
||||
def _collect_column_notes(
|
||||
col: int,
|
||||
string_pitches: list[int],
|
||||
*,
|
||||
masterbars: list,
|
||||
bars_by_id: dict,
|
||||
voices_dict: dict,
|
||||
beats_dict: dict,
|
||||
notes_dict: dict,
|
||||
rhythms_dict: dict,
|
||||
tempo_map: list,
|
||||
tempo_bpm: float,
|
||||
audio_offset: float,
|
||||
) -> list['RsNote']:
|
||||
"""Walk one ``MasterBar/Bars`` column (a single stave / hand) and return its
|
||||
notes as keys-encoded ``RsNote`` (``string = midi // 24``, ``fret = midi %
|
||||
24``). Tie destinations extend the matching prior note's sustain (keyed by
|
||||
pitch, so polyphonic parts are handled) rather than emitting a new note —
|
||||
mirroring the main ``convert_file`` builder, including its full-precision
|
||||
timing and the 0.2s sustain threshold.
|
||||
|
||||
Shared by the GPX LH/RH pair merge and the GP8 multi-stave (grand-staff)
|
||||
fold so the two code paths can never drift in tie / timing / dedup handling.
|
||||
"""
|
||||
from gp2rs import RsNote # lazy: gp2rs<->gpx circular import (see convert_file)
|
||||
|
||||
notes: list[RsNote] = []
|
||||
last_per_key: dict[int, RsNote] = {}
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_bar, next_bpm = next(tempo_iter, (999999, tempo_bpm))
|
||||
cur_tempo = tempo_bpm
|
||||
t_cursor = 0.0
|
||||
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_bar:
|
||||
cur_tempo = next_bpm
|
||||
next_bar, next_bpm = next(tempo_iter, (999999, cur_tempo))
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
nb, db = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
nb, db = 4, 4
|
||||
bar_dur = nb * (4.0 / db) * (60.0 / cur_tempo)
|
||||
bar_ids = mb.findtext('Bars', '').split()
|
||||
bid = bar_ids[col] if col < len(bar_ids) else '-1'
|
||||
if bid != '-1' and bid:
|
||||
bar = bars_by_id.get(bid)
|
||||
if bar is not None:
|
||||
for vid in bar.findtext('Voices', '').split():
|
||||
if vid == '-1':
|
||||
continue
|
||||
voice = voices_dict.get(vid)
|
||||
if voice is None:
|
||||
continue
|
||||
vt = t_cursor
|
||||
for beat_id in voice.findtext('Beats', '').split():
|
||||
beat = beats_dict.get(beat_id)
|
||||
if beat is None:
|
||||
continue
|
||||
dur = _beat_dur_secs(beat, rhythms_dict, cur_tempo)
|
||||
for nid in beat.findtext('Notes', '').strip().split():
|
||||
note_el = notes_dict.get(nid)
|
||||
if note_el is None:
|
||||
continue
|
||||
if _note_is_tie(note_el):
|
||||
tie_midi = _note_midi(note_el, string_pitches)
|
||||
if tie_midi is not None:
|
||||
prev = last_per_key.get(tie_midi)
|
||||
tie_t = vt + audio_offset
|
||||
if prev is not None and prev.time < tie_t:
|
||||
prev.sustain = max(
|
||||
prev.sustain, (tie_t + dur) - prev.time)
|
||||
continue
|
||||
midi = _note_midi(note_el, string_pitches)
|
||||
if midi is None:
|
||||
continue
|
||||
rn = RsNote(
|
||||
time=vt + audio_offset,
|
||||
string=midi // 24,
|
||||
fret=midi % 24,
|
||||
sustain=dur if dur > 0.2 else 0.0,
|
||||
)
|
||||
notes.append(rn)
|
||||
last_per_key[midi] = rn
|
||||
vt += dur
|
||||
t_cursor += bar_dur
|
||||
return notes
|
||||
|
||||
|
||||
def _merge_lh_notes(rs_notes: list, rs_chords: list, lh_notes: list) -> None:
|
||||
"""Fold ``lh_notes`` (a second stave / left hand) into ``rs_notes`` in
|
||||
place, de-duplicating simultaneous same-pitch notes and keeping the LONGER
|
||||
sustain when both hands strike the same key at the same instant. Seeds the
|
||||
dedup set from chord notes too (polyphonic RH beats live in
|
||||
``rs_chords[*].notes``). No-op for an empty ``lh_notes``."""
|
||||
if not lh_notes:
|
||||
return
|
||||
seen: dict[tuple, RsNote] = {}
|
||||
for n in rs_notes:
|
||||
seen.setdefault((round(n.time, 3), n.string, n.fret), n)
|
||||
for c in rs_chords:
|
||||
for cn in c.notes:
|
||||
seen.setdefault((round(cn.time, 3), cn.string, cn.fret), cn)
|
||||
for rn in lh_notes:
|
||||
k = (round(rn.time, 3), rn.string, rn.fret)
|
||||
existing = seen.get(k)
|
||||
if existing is None:
|
||||
rs_notes.append(rn)
|
||||
seen[k] = rn
|
||||
elif rn.sustain > existing.sustain:
|
||||
# Mutating the RsNote also updates it in place inside any RH chord.
|
||||
existing.sustain = rn.sustain
|
||||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drum encoding tables — ported from alphaTab PercussionMapper (MIT licensed)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1366,17 +1523,16 @@ def convert_file(
|
||||
rhythms_dict = {r.get('id'): r for r in (root.find('Rhythms') or [])}
|
||||
_bend_divisor = _gpx_bend_scale(root) # GPIF bend value -> semitones
|
||||
|
||||
# Map filtered track index -> raw track index (needed for bar lookup)
|
||||
raw_tracks = list(root.find('Tracks') or [])
|
||||
filtered_to_raw: dict[int, int] = {}
|
||||
filtered_pos = 0
|
||||
for raw_idx, t_el in enumerate(raw_tracks):
|
||||
name = (t_el.findtext('Name') or '').strip()
|
||||
if name.startswith('@$') and name.endswith('$@'):
|
||||
continue
|
||||
filtered_to_raw[filtered_pos] = raw_idx
|
||||
filtered_pos += 1
|
||||
|
||||
# Map filtered track index -> bar column (MasterBar/Bars position for
|
||||
# stave 0 of that track). `_gpif_tracks` already computed the per-stave
|
||||
# column layout (advancing by num_staves per track, pseudo-tracks skipped),
|
||||
# so reuse its `stave_columns[0]` rather than re-deriving the counting rule
|
||||
# here — divergence in stave counting *is* the bug class this fix closes.
|
||||
# NB: despite the historical name, the value is a bar *column*, not a raw
|
||||
# Track index — do not index `root.find('Tracks')` with it.
|
||||
filtered_to_raw: dict[int, int] = {
|
||||
i: t['stave_columns'][0] for i, t in enumerate(tracks)
|
||||
}
|
||||
|
||||
# Detect and merge Piano LH+RH pairs into single full-keyboard arrangements
|
||||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||||
@@ -1468,7 +1624,16 @@ def convert_file(
|
||||
is_keys = (
|
||||
not is_drum and not is_vocal
|
||||
and (
|
||||
any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||||
# A multi-stave track is a grand staff (treble + bass) — i.e. a
|
||||
# keyboard-family part. Treating it as keys end-to-end keeps the
|
||||
# stave-0 encoding and the folded stave-1+ encoding consistent
|
||||
# (both midi//24, midi%24) and makes the `note_count` preview
|
||||
# (which sums every stave column) match what actually imports,
|
||||
# even for instruments the name/program heuristics miss (harp,
|
||||
# celesta, marimba). GPIF writes guitars as a single Staff, so
|
||||
# this does not sweep in ordinary fretted tracks.
|
||||
track.get('num_staves', 1) > 1
|
||||
or any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||||
or arr_name.lower().startswith('keys')
|
||||
or (
|
||||
not track['string_pitches']
|
||||
@@ -1534,7 +1699,6 @@ def convert_file(
|
||||
pending_slides: list = [] # (RsNote, rs_string, gp_slide_flags) — resolved post-loop
|
||||
|
||||
current_time = 0.0
|
||||
num_raw_tracks = len(raw_tracks)
|
||||
|
||||
# Resolve current tempo per bar from the tempo map
|
||||
_tempo_iter = iter(tempo_map)
|
||||
@@ -1866,112 +2030,20 @@ def convert_file(
|
||||
tuning = _gpx_tuning(track)
|
||||
|
||||
# Merge Piano LH notes into this (RH) arrangement if a pair was detected
|
||||
_walk_kwargs = dict(
|
||||
masterbars=masterbars, bars_by_id=bars_by_id,
|
||||
voices_dict=voices_dict, beats_dict=beats_dict,
|
||||
notes_dict=notes_dict, rhythms_dict=rhythms_dict,
|
||||
tempo_map=tempo_map, tempo_bpm=tempo_bpm, audio_offset=audio_offset,
|
||||
)
|
||||
if is_keys and track_idx in _piano_merge_map:
|
||||
# GPX LH/RH pair: the left hand is a *separate* Track element. Walk
|
||||
# its column and fold it into this (right-hand) arrangement.
|
||||
lh_idx = _piano_merge_map[track_idx]
|
||||
lh_track = tracks[lh_idx]
|
||||
lh_raw_idx = filtered_to_raw.get(lh_idx, lh_idx)
|
||||
|
||||
_lh_notes: list[RsNote] = []
|
||||
_lh_last_per_key: dict[int, RsNote] = {}
|
||||
_lh_tempo_iter = iter(tempo_map)
|
||||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, tempo_bpm))
|
||||
_lh_cur_tempo = tempo_bpm
|
||||
_lh_time = 0.0
|
||||
|
||||
for _lh_mb_idx, _lh_mb in enumerate(masterbars):
|
||||
while _lh_mb_idx >= _lh_next_bar:
|
||||
_lh_cur_tempo = _lh_next_bpm
|
||||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, _lh_cur_tempo))
|
||||
_lh_ts = _lh_mb.findtext('Time', '4/4')
|
||||
try:
|
||||
_lh_nb, _lh_db = [int(x) for x in _lh_ts.split('/')]
|
||||
except ValueError:
|
||||
_lh_nb, _lh_db = 4, 4
|
||||
_lh_bar_dur = _lh_nb * (4.0 / _lh_db) * (60.0 / _lh_cur_tempo)
|
||||
_lh_bar_ids = _lh_mb.findtext('Bars', '').split()
|
||||
_lh_bid = _lh_bar_ids[lh_raw_idx] if lh_raw_idx < len(_lh_bar_ids) else '-1'
|
||||
if _lh_bid != '-1' and _lh_bid:
|
||||
_lh_bar = bars_by_id.get(_lh_bid)
|
||||
if _lh_bar is not None:
|
||||
for _lh_vid in _lh_bar.findtext('Voices', '').split():
|
||||
if _lh_vid == '-1':
|
||||
continue
|
||||
_lh_voice = voices_dict.get(_lh_vid)
|
||||
if _lh_voice is None:
|
||||
continue
|
||||
_lh_vt = _lh_time
|
||||
for _lh_beat_id in _lh_voice.findtext('Beats', '').split():
|
||||
_lh_beat = beats_dict.get(_lh_beat_id)
|
||||
if _lh_beat is None:
|
||||
continue
|
||||
_lh_dur = _beat_dur_secs(_lh_beat, rhythms_dict, _lh_cur_tempo)
|
||||
for _lh_nid in _lh_beat.findtext('Notes', '').strip().split():
|
||||
_lh_note_el = notes_dict.get(_lh_nid)
|
||||
if _lh_note_el is None:
|
||||
continue
|
||||
if _note_is_tie(_lh_note_el):
|
||||
# Extend the matching prior note (same
|
||||
# pitch), mirroring the main builder's
|
||||
# last_note_per_key handling — blindly
|
||||
# extending the last-emitted note
|
||||
# mishandles polyphonic (chord) LH parts.
|
||||
_tie_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||||
if _tie_midi is not None:
|
||||
_prev = _lh_last_per_key.get(_tie_midi)
|
||||
# Full-precision comparison (matching
|
||||
# the main builder); rounding only
|
||||
# happens at XML serialization. Rounding
|
||||
# here could make a short note appear to
|
||||
# start at the tie time and skip the
|
||||
# sustain extension.
|
||||
_tie_t = _lh_vt + audio_offset
|
||||
if _prev is not None and _prev.time < _tie_t:
|
||||
_prev.sustain = max(
|
||||
_prev.sustain,
|
||||
(_tie_t + _lh_dur) - _prev.time,
|
||||
)
|
||||
continue
|
||||
_lh_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||||
if _lh_midi is None:
|
||||
continue
|
||||
# Keep full-precision time (like the main
|
||||
# convert_file() builder — rounding happens at
|
||||
# serialization); same 0.2s sustain threshold.
|
||||
_lh_rn = RsNote(
|
||||
time=_lh_vt + audio_offset,
|
||||
string=_lh_midi // 24,
|
||||
fret=_lh_midi % 24,
|
||||
sustain=_lh_dur if _lh_dur > 0.2 else 0.0,
|
||||
)
|
||||
_lh_notes.append(_lh_rn)
|
||||
_lh_last_per_key[_lh_midi] = _lh_rn
|
||||
_lh_vt += _lh_dur
|
||||
_lh_time += _lh_bar_dur
|
||||
|
||||
# Merge: combine and deduplicate simultaneous same-pitch notes, then
|
||||
# sort by time. Map each (time, string, fret) to its existing RsNote
|
||||
# so that when both hands hit the same key at the same instant we
|
||||
# keep the LONGER sustain instead of arbitrarily discarding the LH
|
||||
# one. Seed from both single notes and chord notes — polyphonic RH
|
||||
# beats live in rs_chords[*].notes, so seeding from rs_notes alone
|
||||
# would let an identical LH note slip in as a duplicate.
|
||||
_seen: dict[tuple, RsNote] = {}
|
||||
for _n in rs_notes:
|
||||
_seen.setdefault((round(_n.time, 3), _n.string, _n.fret), _n)
|
||||
for _c in rs_chords:
|
||||
for _cn in _c.notes:
|
||||
_seen.setdefault((round(_cn.time, 3), _cn.string, _cn.fret), _cn)
|
||||
for _lh_rn in _lh_notes:
|
||||
_k = (round(_lh_rn.time, 3), _lh_rn.string, _lh_rn.fret)
|
||||
_existing = _seen.get(_k)
|
||||
if _existing is None:
|
||||
rs_notes.append(_lh_rn)
|
||||
_seen[_k] = _lh_rn
|
||||
elif _lh_rn.sustain > _existing.sustain:
|
||||
# Same key both hands — preserve the longer sustain (mutating
|
||||
# the RsNote also updates it in place inside any RH chord).
|
||||
_existing.sustain = _lh_rn.sustain
|
||||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||||
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
|
||||
lh_raw_idx, lh_track['string_pitches'], **_walk_kwargs))
|
||||
|
||||
# Collapse "Keys 2" -> "Keys": the merged LH+RH is a single
|
||||
# keyboard arrangement. Keep the standard "Keys" name (not "Piano")
|
||||
@@ -1979,6 +2051,18 @@ def convert_file(
|
||||
# auto-select (which keys on arr_name.startswith("keys")) still work.
|
||||
arr_name = re.sub(r'\s*\d+$', '', arr_name).strip() or 'Keys'
|
||||
|
||||
elif track.get('num_staves', 1) > 1:
|
||||
# GP8 grand-staff keyboard: staves 1+ (bass clef, and any further
|
||||
# staves) are extra MasterBar/Bars columns for the SAME Track
|
||||
# element. Fold each one in, exactly like the GPX LH merge above.
|
||||
# (num_staves > 1 implies is_keys, set above.) Iterating every
|
||||
# extra column — not just stave_columns[1] — keeps the arrangement
|
||||
# consistent with note_count, which sums all columns.
|
||||
for _col, _sp in zip(track['stave_columns'][1:],
|
||||
track['stave_pitches'][1:]):
|
||||
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
|
||||
_col, _sp, **_walk_kwargs))
|
||||
|
||||
# Resolve pending slides now that every note on each string is known.
|
||||
# GPIF slide flags: 1=shift, 2=legato (both slide to the NEXT note on the
|
||||
# string); 4=slide out downwards, 8=slide out upwards (unpitched).
|
||||
@@ -2032,19 +2116,24 @@ def convert_file(
|
||||
try:
|
||||
import gp2notation as _gp2notation
|
||||
_lh_idx = _piano_merge_map.get(track_idx)
|
||||
if _lh_idx is not None:
|
||||
# GPX LH/RH pair (two separate Track elements)
|
||||
_nt_lh_raw = filtered_to_raw.get(_lh_idx, _lh_idx)
|
||||
_nt_lh_sp = tracks[_lh_idx]['string_pitches']
|
||||
elif track.get('num_staves', 1) > 1:
|
||||
# GP8 two-stave piano (one Track with multiple <Staves>)
|
||||
_nt_lh_raw = track['stave_columns'][1]
|
||||
_nt_lh_sp = (track['stave_pitches'][1]
|
||||
if len(track.get('stave_pitches', [])) > 1 else [])
|
||||
else:
|
||||
_nt_lh_raw, _nt_lh_sp = None, []
|
||||
_payload = _gp2notation.convert_track_to_notation(
|
||||
root, raw_idx, track['string_pitches'],
|
||||
instrument='piano',
|
||||
audio_offset=audio_offset,
|
||||
track_name=track['name'],
|
||||
lh_raw_idx=(
|
||||
filtered_to_raw.get(_lh_idx, _lh_idx)
|
||||
if _lh_idx is not None else None
|
||||
),
|
||||
lh_string_pitches=(
|
||||
tracks[_lh_idx]['string_pitches']
|
||||
if _lh_idx is not None else None
|
||||
),
|
||||
lh_raw_idx=_nt_lh_raw,
|
||||
lh_string_pitches=_nt_lh_sp or None,
|
||||
)
|
||||
_gp2notation.write_notation_sidecar(filepath, _payload)
|
||||
except Exception:
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
"""Text-matching engine for MusicBrainz metadata enrichment (P8).
|
||||
|
||||
Pure functions only — no network, no database, no server imports — so the
|
||||
whole matching pipeline is unit-testable in isolation. server.py owns the
|
||||
throttled HTTP transport and the song_enrichment writes; this module owns:
|
||||
|
||||
* denoise/tokenize: fold community chart-title noise (author suffixes,
|
||||
``(440Hz)``/``(Live)``/``(No Lead)``/``(v2)`` parentheticals, punctuation,
|
||||
diacritics, ``AC DC``/``ACDC``/``AC/DC`` spelling drift) into a comparable
|
||||
token form,
|
||||
* similarity + scoring: token-set similarity on artist+title with year and
|
||||
duration proximity as corroborating bonuses,
|
||||
* tier classification: auto (high) / review (medium) / none (low) — the
|
||||
design rule is that a WRONG match is worse than no match, so the auto
|
||||
tier is deliberately strict and medium confidence goes to a human,
|
||||
* MusicBrainz JSON parsing: normalize ``/ws/2`` recording documents into
|
||||
the flat candidate dicts the review UI and song_enrichment store.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# ── Tier thresholds ───────────────────────────────────────────────────────────
|
||||
# Combined score = 0.5*artist_sim + 0.5*title_sim + corroboration bonuses
|
||||
# (capped at 1.0). Wrong-match is worse than slow (design §5), so `auto`
|
||||
# additionally requires BOTH fields to individually agree — a perfect title
|
||||
# with a mismatched artist (a cover) must never auto-canonicalize, whatever
|
||||
# the combined threshold is set to. AUTO_MIN is only the DEFAULT: the host
|
||||
# surfaces it as the user-configurable "auto-apply confidence" setting and
|
||||
# passes the chosen value into classify(auto_min=…).
|
||||
AUTO_MIN = 0.90
|
||||
AUTO_ARTIST_MIN = 0.8
|
||||
AUTO_TITLE_MIN = 0.6
|
||||
REVIEW_MIN = 0.65
|
||||
|
||||
YEAR_BONUS = 0.05 # candidate year within ±1 of the chart's year
|
||||
DURATION_BONUS = 0.05 # candidate length within 5s of the chart's audio
|
||||
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,
|
||||
# performance qualifiers) or when it reads as an author credit ("by X",
|
||||
# "charted by X"). Both sides of a comparison are denoised symmetrically, so
|
||||
# over-stripping a meaningful group costs a little precision but never
|
||||
# produces an asymmetric mismatch.
|
||||
_NOISE_TERMS = (
|
||||
r"440\s*hz", r"a440", r"432\s*hz",
|
||||
r"live", r"acoustic", r"instrumental",
|
||||
r"no\s+(?:lead|rhythm|bass|vocals?|drums)",
|
||||
r"(?:lead|rhythm|bass)\s+only",
|
||||
r"v\d+", r"ver(?:sion)?\s*\d+",
|
||||
r"remaster(?:ed)?(?:\s*\d{4})?", r"re-?recorded?",
|
||||
r"fix(?:ed)?", r"updated?",
|
||||
r"bonus", r"custom",
|
||||
)
|
||||
_NOISE_GROUP_RE = re.compile(
|
||||
r"[(\[][^)\]]*\b(?:" + "|".join(_NOISE_TERMS) + r")\b[^)\]]*[)\]]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Author credits: "(by SomeCharter)", "[charted by X]", "(chart by X)".
|
||||
_AUTHOR_GROUP_RE = re.compile(
|
||||
r"[(\[]\s*(?:chart(?:ed)?\s+)?by\s+[^)\]]*[)\]]", re.IGNORECASE)
|
||||
# Trailing "- by SomeCharter" outside parens.
|
||||
_AUTHOR_TAIL_RE = re.compile(r"\s+-\s+(?:chart(?:ed)?\s+)?by\s+.+$", re.IGNORECASE)
|
||||
|
||||
_PUNCT_RE = re.compile(r"[^\w\s]|_")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _strip_diacritics(s: str) -> str:
|
||||
return "".join(
|
||||
ch for ch in unicodedata.normalize("NFKD", s)
|
||||
if not unicodedata.combining(ch)
|
||||
)
|
||||
|
||||
|
||||
def denoise(s, *, strip_leading_the: bool = False) -> str:
|
||||
"""Fold a community metadata string into its comparable form:
|
||||
lowercase, diacritics stripped, noise parentheticals and author credits
|
||||
removed, punctuation collapsed to spaces. ``strip_leading_the`` drops a
|
||||
leading "The " — used for ARTIST comparison only ("The Beatles" ==
|
||||
"Beatles"), never titles ("The Trooper" must keep its "the")."""
|
||||
s = str(s or "")
|
||||
s = _NOISE_GROUP_RE.sub(" ", s)
|
||||
s = _AUTHOR_GROUP_RE.sub(" ", s)
|
||||
s = _AUTHOR_TAIL_RE.sub(" ", s)
|
||||
s = _strip_diacritics(s).casefold()
|
||||
s = s.replace("&", " and ")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
s = _WS_RE.sub(" ", s).strip()
|
||||
if strip_leading_the and s.startswith("the "):
|
||||
s = s[4:]
|
||||
return s
|
||||
|
||||
|
||||
def tokens(s, **kw) -> list[str]:
|
||||
d = denoise(s, **kw)
|
||||
return d.split() if d else []
|
||||
|
||||
|
||||
def _compact(toks: list[str]) -> str:
|
||||
return "".join(toks)
|
||||
|
||||
|
||||
def similarity(a, b, *, artist: bool = False) -> float:
|
||||
"""Token-set similarity in [0, 1]. Dice coefficient over the denoised
|
||||
token sets, with a compacted-string equality fold so spelling drift that
|
||||
only moves token boundaries ("ACDC" / "AC DC" / "AC/DC", "Greenday" /
|
||||
"Green Day") counts as identical."""
|
||||
kw = {"strip_leading_the": artist}
|
||||
ta, tb = tokens(a, **kw), tokens(b, **kw)
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
if _compact(ta) == _compact(tb):
|
||||
return 1.0
|
||||
sa, sb = set(ta), set(tb)
|
||||
return 2.0 * len(sa & sb) / (len(sa) + len(sb))
|
||||
|
||||
|
||||
def _year_int(v):
|
||||
try:
|
||||
y = int(str(v)[:4])
|
||||
return y if y > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _duration_int(v):
|
||||
try:
|
||||
d = int(round(float(v)))
|
||||
return d if d > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
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"))
|
||||
if sy and cy and abs(sy - cy) <= 1:
|
||||
score += YEAR_BONUS
|
||||
sd, cd = _duration_int(song.get("duration")), _duration_int(cand.get("duration"))
|
||||
if sd and cd:
|
||||
diff = abs(sd - cd)
|
||||
if diff <= _DURATION_TIGHT:
|
||||
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)
|
||||
|
||||
|
||||
def classify(song: dict, cand: dict, score: float, auto_min: float | None = None) -> str:
|
||||
"""Tier for a scored candidate: 'auto' | 'review' | 'none'.
|
||||
|
||||
`auto` (tier-2) needs the combined score AND per-field agreement AND
|
||||
both fields present — a perfect-title/wrong-artist cover, or a chart
|
||||
with no artist at all, is at best a review item, never an auto match.
|
||||
`auto_min` overrides the default combined-score threshold (the user's
|
||||
"auto-apply confidence" setting); the per-field floors always apply.
|
||||
"""
|
||||
if auto_min is None:
|
||||
auto_min = AUTO_MIN
|
||||
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
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):
|
||||
return "auto"
|
||||
if score >= REVIEW_MIN:
|
||||
return "review"
|
||||
return "none"
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""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"],
|
||||
(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
|
||||
|
||||
|
||||
# ── MusicBrainz query + response parsing ──────────────────────────────────────
|
||||
|
||||
def _lucene_escape_phrase(s: str) -> str:
|
||||
"""Escape a string for use inside a quoted Lucene phrase."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# 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) -> 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."""
|
||||
t = denoise(title)
|
||||
a = denoise(artist)
|
||||
parts = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
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]:
|
||||
"""(display name, artist mbid, sort name) from an artist-credit array."""
|
||||
credits = doc.get("artist-credit") or []
|
||||
name = ""
|
||||
for part in credits:
|
||||
if isinstance(part, dict):
|
||||
name += str(part.get("name", "")) + str(part.get("joinphrase", "") or "")
|
||||
else: # ws/2 can emit bare join strings in older serializations
|
||||
name += str(part)
|
||||
first = next((p for p in credits if isinstance(p, dict)), None) or {}
|
||||
artist = first.get("artist") or {}
|
||||
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 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):
|
||||
rg = r.get("release-group") or {}
|
||||
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")
|
||||
# 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]
|
||||
|
||||
|
||||
def _genres(doc: dict, limit: int = 5) -> list[str]:
|
||||
"""Genre names from a recording doc. Search results carry folksonomy
|
||||
`tags`; lookups with inc=genres carry curated `genres`. Both are
|
||||
[{name, count}] — take the most-voted few."""
|
||||
raw = doc.get("genres") or doc.get("tags") or []
|
||||
entries = [e for e in raw if isinstance(e, dict) and e.get("name")]
|
||||
entries.sort(key=lambda e: e.get("count") or 0, reverse=True)
|
||||
return [str(e["name"]) for e in entries[:limit]]
|
||||
|
||||
|
||||
def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"""Normalize one /ws/2 recording document (search hit or direct lookup)
|
||||
into the flat candidate dict stored in song_enrichment.candidates and
|
||||
rendered by the review drawer. Returns None for malformed docs."""
|
||||
if not isinstance(doc, dict) or not doc.get("id") or not doc.get("title"):
|
||||
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
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
isrcs = doc.get("isrcs") or []
|
||||
isrcs = [str(i) for i in isrcs if isinstance(i, (str,))]
|
||||
return {
|
||||
"recording_id": str(doc["id"]),
|
||||
"title": str(doc.get("title", "")),
|
||||
"artist": artist_name,
|
||||
"artist_id": artist_id,
|
||||
"artist_sort": artist_sort,
|
||||
"release_id": str(release.get("id", "") or ""),
|
||||
"album": str(release.get("title", "") or ""),
|
||||
"year": str(release.get("date", "") or "")[:4],
|
||||
"duration": duration,
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
def parse_search_response(body: dict) -> list[dict]:
|
||||
"""Candidates from a /ws/2/recording search response."""
|
||||
docs = (body or {}).get("recordings") or []
|
||||
out = []
|
||||
for doc in docs:
|
||||
cand = parse_recording_doc(doc)
|
||||
if cand:
|
||||
out.append(cand)
|
||||
return out
|
||||
+21
-5
@@ -114,11 +114,27 @@ def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
|
||||
pitches = sorted(n["midi"] for n in group)
|
||||
span = pitches[-1] - pitches[0]
|
||||
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
|
||||
# Largest internal gap; ties resolve to the lowest such gap so the
|
||||
# left hand keeps the tight low cluster.
|
||||
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
|
||||
split_after = gaps.index(max(gaps))
|
||||
threshold = pitches[split_after] # lh: midi <= threshold
|
||||
# Prefer middle C as the split boundary when notes straddle it —
|
||||
# this correctly handles bass+treble chords from piano imports where
|
||||
# the largest-gap heuristic picks the wrong split point (e.g.
|
||||
# [G2, E3, C4]: largest gap is G2→E3 but the real split is E3|C4).
|
||||
# BUT only when both resulting hands are themselves playable: a bass
|
||||
# note under a treble voicing that merely dips below C4 (e.g.
|
||||
# [E2, B3, D4, G4]) would otherwise land E2+B3 in one hand — a
|
||||
# 19-semitone span that re-violates HAND_SPLIT_SPAN_SEMITONES. When
|
||||
# the middle-C split produces an unplayable hand, fall back to the
|
||||
# largest internal gap (which correctly isolates E2 there).
|
||||
threshold = None
|
||||
if pitches[0] < MIDDLE_C <= pitches[-1]:
|
||||
_lh = [p for p in pitches if p < MIDDLE_C]
|
||||
_rh = [p for p in pitches if p >= MIDDLE_C]
|
||||
if (_lh[-1] - _lh[0] <= HAND_SPLIT_SPAN_SEMITONES
|
||||
and _rh[-1] - _rh[0] <= HAND_SPLIT_SPAN_SEMITONES):
|
||||
threshold = MIDDLE_C - 1 # lh: midi < MIDDLE_C
|
||||
if threshold is None:
|
||||
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
|
||||
split_after = gaps.index(max(gaps))
|
||||
threshold = pitches[split_after]
|
||||
for n in group:
|
||||
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
|
||||
else:
|
||||
|
||||
@@ -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()
|
||||
|
||||
+7
-6
@@ -109,13 +109,14 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
||||
the root it already resolved; in-process callers can pass the resolver
|
||||
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
|
||||
|
||||
FeedBack reads only its own `.sloppak` format and loose-folder XML
|
||||
songs. Encrypted/proprietary archive formats are not supported and are
|
||||
silently ignored (empty metadata) rather than decrypted.
|
||||
FeedBack reads only its own song-package format (`.feedpak` / legacy
|
||||
`.sloppak`) and loose-folder XML songs. Encrypted/proprietary archive
|
||||
formats are not supported and are silently ignored (empty metadata)
|
||||
rather than decrypted.
|
||||
"""
|
||||
# Sloppak is detected by `.sloppak` suffix only (cheap), so check it
|
||||
# first — that way a user's loose folder named `foo.sloppak` still wins
|
||||
# the sloppak branch instead of being misclassified.
|
||||
# Packages are detected by suffix only (`.feedpak`/`.sloppak`, cheap), so
|
||||
# check that first — that way a user's loose folder named `foo.feedpak`
|
||||
# still wins the package branch instead of being misclassified.
|
||||
if sloppak_mod.is_sloppak(path):
|
||||
return _extract_meta_sloppak(path)
|
||||
if loosefolder_mod.is_loose_song(path):
|
||||
|
||||
@@ -919,6 +919,12 @@ def extract_meta(path: Path) -> dict:
|
||||
"artist": str(manifest.get("artist", "")),
|
||||
"album": str(manifest.get("album", "")),
|
||||
"year": str(manifest.get("year", "") or ""),
|
||||
# Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary.
|
||||
"genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")),
|
||||
# Album track order from the feedpak `track`/`disc` fields (spec 1.12.0);
|
||||
# None when unauthored (the album view then falls back to title order).
|
||||
"track_number": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("track")),
|
||||
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
|
||||
"duration": float(manifest.get("duration", 0) or 0),
|
||||
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||
"arrangements": arrangements,
|
||||
|
||||
+61
-5
@@ -10,9 +10,9 @@ source of truth, so the change survives both incremental and full rescans.
|
||||
only the keys present are overwritten, so an edit of just the title can't blank
|
||||
out the artist.
|
||||
|
||||
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
|
||||
Unknown / unsupported shapes return False and the caller keeps the DB-only
|
||||
update.
|
||||
Only feedBack's own song-package format (zip- or directory-form, ``.feedpak``
|
||||
or the legacy ``.sloppak`` suffix) is writable. Unknown / unsupported shapes
|
||||
return False and the caller keeps the DB-only update.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -107,19 +107,75 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
|
||||
return _rewrite_zip_manifest(path, dumped)
|
||||
|
||||
|
||||
def gap_fill_sloppak(path: Path, additions: dict) -> bool:
|
||||
"""Append ABSENT top-level keys to a sloppak manifest (the gap-fill
|
||||
contract: user-initiated, adds missing keys only, never replaces
|
||||
anything the author set).
|
||||
|
||||
Unlike ``write_sloppak_metadata`` this does NOT re-serialize the
|
||||
manifest — because every added key is absent by definition, the new
|
||||
lines can simply be appended, so the author's existing bytes (key
|
||||
order, comments, formatting) survive verbatim. Directory form gets a
|
||||
one-time ``manifest.yaml.bak`` + temp + atomic replace; zip form goes
|
||||
through the same backup/temp/replace rewriter the metadata editor
|
||||
uses. Returns True if anything was written; raises ``ValueError`` if
|
||||
a requested key already exists (callers are expected to have checked
|
||||
— this is the last-line never-clobber guard)."""
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
path = Path(path)
|
||||
if not additions:
|
||||
return False
|
||||
manifest = sloppak_mod.load_manifest(path) or {}
|
||||
clash = sorted(k for k in additions if k in manifest)
|
||||
if clash:
|
||||
raise ValueError("gap-fill refused: key(s) already present: " + ", ".join(clash))
|
||||
|
||||
if path.is_dir():
|
||||
mf = path / "manifest.yaml"
|
||||
if not mf.exists() and (path / "manifest.yml").exists():
|
||||
mf = path / "manifest.yml"
|
||||
original = mf.read_text(encoding="utf-8")
|
||||
else:
|
||||
with zipfile.ZipFile(str(path), "r") as zin:
|
||||
names = zin.namelist()
|
||||
manifest_name = "manifest.yaml"
|
||||
for cand in ("manifest.yaml", "manifest.yml"):
|
||||
if cand in names:
|
||||
manifest_name = cand
|
||||
break
|
||||
original = zin.read(manifest_name).decode("utf-8")
|
||||
|
||||
appended = original if original.endswith("\n") or not original else original + "\n"
|
||||
appended += yaml.safe_dump(additions, sort_keys=False, allow_unicode=True)
|
||||
|
||||
if path.is_dir():
|
||||
backup = mf.with_name(mf.name + ".bak")
|
||||
if not backup.exists():
|
||||
shutil.copy2(mf, backup)
|
||||
tmp = mf.with_name(mf.name + ".tmp")
|
||||
tmp.write_text(appended, encoding="utf-8")
|
||||
tmp.replace(mf)
|
||||
return True
|
||||
return _rewrite_zip_manifest(path, appended)
|
||||
|
||||
|
||||
def write_song_metadata(path: Path, fields: dict) -> bool:
|
||||
"""Persist edited title/artist/album/year into the song's file.
|
||||
|
||||
Dispatches by shape: ``.sloppak`` files and sloppak directories
|
||||
Dispatches by shape: zip-form song packages (``.feedpak`` / legacy
|
||||
``.sloppak``, per ``sloppak.SONG_EXTS``) and package directories
|
||||
(manifest.yaml present). Loose-folder and unknown shapes return False
|
||||
(caller keeps the DB-only update). Returns True if the file was modified.
|
||||
"""
|
||||
from sloppak import SONG_EXTS
|
||||
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
if path.is_dir():
|
||||
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
|
||||
return write_sloppak_metadata(path, fields)
|
||||
return False
|
||||
if suffix == ".sloppak":
|
||||
if suffix in SONG_EXTS:
|
||||
return write_sloppak_metadata(path, fields)
|
||||
return False
|
||||
|
||||
+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
|
||||
|
||||
@@ -71,6 +71,24 @@ tunes one of these, mirror the change here (and in `keys_highway_3d`):
|
||||
mapping switch in `draw()`; addons dynamic-imported from
|
||||
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
||||
graceful degrade)
|
||||
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
|
||||
(pool 160 here vs the guitar's 256)
|
||||
- `_makeGaussTex()` — soft-falloff DataTexture for the additive lane-flash
|
||||
quads
|
||||
- `_timingHex()` — early/late/on-time feedback colors (green/cyan/amber)
|
||||
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
|
||||
checks, which it needs for input routing and we don't)
|
||||
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
|
||||
as the guitar's, except `default` which is this plugin's original
|
||||
palette); one pick drives both of the guitar's background/highway axes
|
||||
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
|
||||
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
|
||||
`_bgReadBands()` — background ambience + the stems-first audio-analyser
|
||||
bridge (guitar's diagnostics plumbing dropped; butterchurn/image/video
|
||||
out of scope)
|
||||
- `_drawScoreFx()` — the guitar's drawScoreFx overlay adapted to this
|
||||
plugin's internal scoring (pops / tier rings / milestone bursts /
|
||||
streak-break wash)
|
||||
|
||||
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "drum_highway_3d",
|
||||
"name": "3D Drum Highway",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+1231
-46
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,99 @@
|
||||
<!-- Graphics -->
|
||||
<div class="mt-6 border-t border-gray-800 pt-4">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
|
||||
<label for="drumh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="drumh3d-fx-theme"
|
||||
onchange="window.drumH3dSetTheme && window.drumH3dSetTheme(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="default">Default (original)</option>
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="charcoal">Charcoal</option>
|
||||
<option value="deeppurple">Deep Purple</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="warmslate">Warm Slate</option>
|
||||
<option value="deepfocus">Deep Focus</option>
|
||||
<option value="deepsea">Deep Sea</option>
|
||||
<option value="cathode">Cathode</option>
|
||||
<option value="cathodegreen">Cathode Green</option>
|
||||
<option value="hearth">Hearth</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background, floor and lane colours — the same theme names as the
|
||||
guitar highway, so your look carries across instruments. Piece
|
||||
colours stay with the Palette above.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="drumh3d-fx-cinematic" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('cinematic', this.checked)">
|
||||
Cinematic lighting
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Dimmer ambience, stronger key light — more depth on the cymbals
|
||||
and drumheads.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Glow strength <span id="drumh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-glow"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('glow', this.value); document.getElementById('drumh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How hard the notes and hit line self-illuminate (0.5 = stock).
|
||||
Pairs with bloom.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Lane vibrancy <span id="drumh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-vibrancy"
|
||||
min="0" max="1" step="0.05" value="0.85"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('vibrancy', this.value); document.getElementById('drumh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Lane stripe and accent-ring strength — lower for a calmer deck.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
|
||||
<select id="drumh3d-fx-bgstyle"
|
||||
onchange="window.drumH3dSetBgStyle && window.drumH3dSetBgStyle(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="off">Off</option>
|
||||
<option value="particles" selected>Particles</option>
|
||||
<option value="lights">Stage lights</option>
|
||||
<option value="geometric">Geometric</option>
|
||||
</select>
|
||||
<label for="drumh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
|
||||
Ambience intensity <span id="drumh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-bgintensity"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('bgIntensity', this.value); document.getElementById('drumh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<label for="drumh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
|
||||
<input type="checkbox" id="drumh3d-fx-bgreactive" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bgReactive', this.checked)">
|
||||
Audio-reactive ambience
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
The backdrop pulses with the mix (stems analyser when a sloppak
|
||||
is loaded). Off = it animates on time only.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-scorefx" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('scoreFx', this.checked)">
|
||||
Score effects
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
+1 pops on hits, a ring pulse every 10-combo, milestone bursts at
|
||||
25/50/100, and a brief red flicker when a streak breaks.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-bloom" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
|
||||
Glow (bloom)
|
||||
@@ -54,6 +146,46 @@
|
||||
Soft light-bleed around the hit line and bright notes. Applies
|
||||
live; turn off to reclaim GPU headroom on weak machines.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-sparks" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('sparks', this.checked)">
|
||||
Hit sparks
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
A small particle burst on every scored pad hit.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-timing" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('timingFx', this.checked)">
|
||||
Timing colours
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint hit feedback by timing — on-time green, early cyan, late
|
||||
amber. Off = always green.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-streak" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('streakFx', this.checked)">
|
||||
Streak feedback
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Spark bursts grow with your combo.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Hit feedback intensity <span id="drumh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-hitfx"
|
||||
min="0" max="1" step="0.05" value="0.7"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('hitFx', this.value); document.getElementById('drumh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Drives the lane flashes, approach glow and the kick camera pulse.
|
||||
0 turns them off.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MIDI input -->
|
||||
@@ -440,11 +572,38 @@
|
||||
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
|
||||
// Only explicit values override; absent/corrupt keys keep the
|
||||
// default (ON), matching screen.js readFxSettings.
|
||||
const storedBloom = localStorage.getItem('drum_h3d_bg_bloom');
|
||||
if (storedBloom === '1' || storedBloom === 'true') {
|
||||
document.getElementById('drumh3d-fx-bloom').checked = true;
|
||||
} else if (storedBloom === '0' || storedBloom === 'false') {
|
||||
document.getElementById('drumh3d-fx-bloom').checked = false;
|
||||
const hydrateFxBool = (key, elId) => {
|
||||
const raw = localStorage.getItem('drum_h3d_bg_' + key);
|
||||
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
|
||||
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
|
||||
};
|
||||
hydrateFxBool('bloom', 'drumh3d-fx-bloom');
|
||||
hydrateFxBool('sparks', 'drumh3d-fx-sparks');
|
||||
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
|
||||
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
|
||||
hydrateFxBool('cinematic', 'drumh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'drumh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'drumh3d-fx-scorefx');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('drum_h3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const v = Math.min(1, Math.max(0, n));
|
||||
document.getElementById(elId).value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRange('hitFx', 'drumh3d-fx-hitfx', 'drumh3d-fx-hitfx-val');
|
||||
hydrateFxRange('glow', 'drumh3d-fx-glow', 'drumh3d-fx-glow-val');
|
||||
hydrateFxRange('vibrancy', 'drumh3d-fx-vibrancy', 'drumh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('bgIntensity', 'drumh3d-fx-bgintensity', 'drumh3d-fx-bgintensity-val');
|
||||
const storedStyle = localStorage.getItem('drum_h3d_bg_style');
|
||||
const styleSel = document.getElementById('drumh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
styleSel.value = storedStyle;
|
||||
}
|
||||
const storedTheme = localStorage.getItem('drum_h3d_bg_theme');
|
||||
const themeSel = document.getElementById('drumh3d-fx-theme');
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
|
||||
|
||||
@@ -92,3 +92,73 @@ test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
|
||||
// ±50 ms window matches the 2D drums plugin.
|
||||
assert.equal(HIT_TOLERANCE_S, 0.05);
|
||||
});
|
||||
|
||||
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
|
||||
const { _classifyTiming, HIT_TOLERANCE_S } = load().__test;
|
||||
const tol = HIT_TOLERANCE_S; // 0.05
|
||||
assert.equal(_classifyTiming(0, tol), 'OK');
|
||||
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK'); // boundary inclusive
|
||||
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
|
||||
// delta = note.t - now: positive → struck before the note → EARLY.
|
||||
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
|
||||
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
|
||||
assert.equal(_classifyTiming(tol, tol), 'EARLY');
|
||||
assert.equal(_classifyTiming(-tol, tol), 'LATE');
|
||||
// Degenerate inputs read as on-time rather than throwing.
|
||||
assert.equal(_classifyTiming(NaN, tol), 'OK');
|
||||
});
|
||||
|
||||
test('FX defaults: hit-FX controls ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().__test;
|
||||
assert.equal(FX_DEFAULTS.sparks, true);
|
||||
assert.equal(FX_DEFAULTS.timingFx, true);
|
||||
assert.equal(FX_DEFAULTS.streakFx, true);
|
||||
assert.equal(FX_DEFAULTS.hitFx, 0.7);
|
||||
});
|
||||
|
||||
test('themes: table ids match the guitar highway, default is the stock palette', () => {
|
||||
const { BG_THEMES, _bgThemeColors } = load().__test;
|
||||
// Same id set as highway_3d's BG_THEMES (cross-instrument consistency).
|
||||
assert.deepEqual(Object.keys(BG_THEMES), [
|
||||
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
|
||||
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
|
||||
]);
|
||||
// 'default' preserves THIS plugin's original look byte-for-byte.
|
||||
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
|
||||
assert.equal(BG_THEMES.default.board, 0x0a0e1a);
|
||||
assert.equal(BG_THEMES.default.lane, undefined); // stock stripes fallback
|
||||
// Unknown ids fall back to default.
|
||||
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
|
||||
// Every non-default theme carries a lane pair (the axis differentiator).
|
||||
for (const [id, t] of Object.entries(BG_THEMES)) {
|
||||
if (id === 'default') continue;
|
||||
assert.ok(t.lane != null && t.laneDim != null, id + ' lane pair');
|
||||
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
|
||||
}
|
||||
});
|
||||
|
||||
test('readThemeSetting: defaults without localStorage; validates ids', () => {
|
||||
const { readThemeSetting } = load().__test;
|
||||
assert.equal(readThemeSetting(), 'default');
|
||||
});
|
||||
|
||||
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
|
||||
const { FX_DEFAULTS } = load().__test;
|
||||
assert.equal(FX_DEFAULTS.cinematic, true);
|
||||
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
|
||||
assert.equal(FX_DEFAULTS.vibrancy, 0.85); // ≈ the stock 0.32 stripe base
|
||||
});
|
||||
|
||||
test('bg styles: validated id set, particles default, no out-of-scope styles', () => {
|
||||
const { BG_STYLE_IDS, readBgStyleSetting } = load().__test;
|
||||
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
|
||||
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
|
||||
assert.equal(readBgStyleSetting(), 'particles'); // no localStorage in the vm
|
||||
});
|
||||
|
||||
test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().__test;
|
||||
assert.equal(FX_DEFAULTS.scoreFx, true);
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
@@ -157,6 +157,8 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
|
||||
|
||||
`tuning` and `capo` aren't consumed by this plugin.
|
||||
|
||||
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
|
||||
|
||||
### Score FX (notedetect game-scoring layer)
|
||||
|
||||
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.30.2",
|
||||
"version": "3.31.2",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+262
-89
@@ -1236,6 +1236,11 @@
|
||||
|
||||
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
|
||||
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
|
||||
// Byte-identical to core's bundle.lowerBoundT — kept as a local because this
|
||||
// plugin must run on downlevel hosts whose bundles don't carry the helper
|
||||
// (it's called from ~30 sites incl. top-level helpers that don't receive a
|
||||
// bundle). New code that already holds a bundle should prefer
|
||||
// bundle.lowerBoundT / bundle.lowerBoundTime.
|
||||
function lowerBoundT(arr, t) {
|
||||
let lo = 0, hi = arr.length;
|
||||
while (lo < hi) {
|
||||
@@ -2191,6 +2196,24 @@
|
||||
}
|
||||
const audio = document.getElementById('audio');
|
||||
if (!audio) return null;
|
||||
// Shared tap: createMediaElementSource is one-shot per element, so
|
||||
// the FIRST visualizer to tap #audio publishes it at
|
||||
// window.__feedBackAudioTap and every later one (this plugin, the
|
||||
// drum/keys 3D highways) adopts it instead of throwing
|
||||
// InvalidStateError when visualizers are switched or mixed in
|
||||
// splitscreen.
|
||||
const sharedTap = window.__feedBackAudioTap;
|
||||
if (sharedTap && sharedTap.analyser && sharedTap.mediaEl === audio) {
|
||||
_bgAudio = {
|
||||
ctx: sharedTap.ctx,
|
||||
analyser: sharedTap.analyser,
|
||||
freq: new Uint8Array(Math.max(BG_FREQ_BINS, sharedTap.analyser.frequencyBinCount)),
|
||||
source: 'core',
|
||||
};
|
||||
_bgAudioCore = _bgAudio;
|
||||
_bgRecordAudioBridge('audio-mix.analyser', 'shared #audio analyser tap', 'handled', '', 'core');
|
||||
return _bgAudio;
|
||||
}
|
||||
// Hoist ctx out of the try so we can close() it if a later step
|
||||
// throws (e.g. createMediaElementSource on an element that
|
||||
// already has a source node). Otherwise the AudioContext leaks.
|
||||
@@ -2205,6 +2228,7 @@
|
||||
source.connect(analyser);
|
||||
analyser.connect(ctx.destination);
|
||||
_bgAudio = { ctx, analyser, freq: new Uint8Array(Math.max(BG_FREQ_BINS, analyser.frequencyBinCount)), source: 'core' };
|
||||
try { window.__feedBackAudioTap = { ctx, analyser, mediaEl: audio }; } catch (_) {}
|
||||
_bgRecordAudioBridge('audio-mix.analyser', 'HTMLAudioElement analyser tap', 'handled', '', 'core');
|
||||
// Remember the core analyser so a later stems-then-back-to-core
|
||||
// transition can re-use it instead of re-tapping #audio (which
|
||||
@@ -3978,6 +4002,10 @@
|
||||
let _laneRailBoundsRefTpl = null;
|
||||
let _laneRailBoundsRefNotes = null;
|
||||
let _lastHwW = 0, _lastHwH = 0;
|
||||
// Frame counter for throttling the CSS-box drift check in draw()
|
||||
// (getBoundingClientRect is a forced layout read; see the comment
|
||||
// at the check).
|
||||
let _boxCheckCountdown = 0;
|
||||
// Last logical (CSS px) size handed to applySize(). #highway is a
|
||||
// flex:1 item, so its real rendered box (canvasSize()) can change as
|
||||
// the player layout settles after a song opens WITHOUT the backing
|
||||
@@ -4677,10 +4705,12 @@
|
||||
// the full look (re-enable the rail bloom) per browser, no rebuild:
|
||||
// localStorage.h3d_full_sus = '1' // re-enable rail bloom halo
|
||||
// delete localStorage.h3d_full_sus // back to lean default
|
||||
// Read once per frame at the top of update() so the flag takes effect
|
||||
// live. The bloom pool/material/gaussian texture are kept intact
|
||||
// Polled at ~1 Hz at the top of update() (perf: localStorage reads
|
||||
// are synchronous) so the console flag still takes effect live.
|
||||
// The bloom pool/material/gaussian texture are kept intact
|
||||
// (still pinned by the bloom unit tests and used by the opt-out path).
|
||||
let _leanSus = true;
|
||||
let _leanSusPollCounter = 0;
|
||||
|
||||
// Lifecycle flags
|
||||
let _isReady = false;
|
||||
@@ -5267,7 +5297,17 @@
|
||||
// appearing on top without depthTest.
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
side: T.DoubleSide,
|
||||
// forceSinglePass accompanies EVERY transparent DoubleSide
|
||||
// material in this file: without it, Three r158+ renders
|
||||
// each such object in TWO passes (back side then front),
|
||||
// setting material.needsUpdate on both — which forces a
|
||||
// full getParameters/program-cache lookup per object per
|
||||
// frame (profiled at ~4% of throttled main-thread time)
|
||||
// and doubles the draw calls. The two-pass path exists to
|
||||
// fix self-occlusion sorting on closed transparent meshes;
|
||||
// all our DoubleSide materials are flat unlit quads
|
||||
// (labels, rails, frames, lanes) where it buys nothing.
|
||||
side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
sm.userData.h3dTechMeshMat = base;
|
||||
}
|
||||
@@ -6250,6 +6290,12 @@
|
||||
return boxH;
|
||||
}
|
||||
|
||||
// Lyrics layout cache — measureText per syllable + row wrapping
|
||||
// only changes when the displayed line(s), font size, or canvas
|
||||
// width change, not per frame. Keyed below; the per-frame work is
|
||||
// just drawing over the cached widths.
|
||||
let _lyrRowsCache = null;
|
||||
|
||||
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
||||
if (!lyrics._lines) {
|
||||
const lines = [];
|
||||
@@ -6297,37 +6343,51 @@
|
||||
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
|
||||
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
const spaceWidth = ctx.measureText(' ').width;
|
||||
const maxWidth = W * 0.8;
|
||||
let rows, spaceWidth, bgWidth;
|
||||
const _lc = _lyrRowsCache;
|
||||
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
|
||||
&& _lc.shown === linesToShow.length
|
||||
&& _lc.fontSize === fontSize && _lc.W === W) {
|
||||
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
|
||||
} else {
|
||||
spaceWidth = ctx.measureText(' ').width;
|
||||
const maxWidth = W * 0.8;
|
||||
|
||||
const rows = [];
|
||||
for (const authoredLine of linesToShow) {
|
||||
let row = [], rowWidth = 0;
|
||||
for (const wordSyls of authoredLine.words) {
|
||||
const parts = [];
|
||||
let wordWidth = 0;
|
||||
for (const s of wordSyls) {
|
||||
const text = sylText(s);
|
||||
const w = ctx.measureText(text).width;
|
||||
parts.push({ syl: s, text, width: w });
|
||||
wordWidth += w;
|
||||
rows = [];
|
||||
for (const authoredLine of linesToShow) {
|
||||
let row = [], rowWidth = 0;
|
||||
for (const wordSyls of authoredLine.words) {
|
||||
const parts = [];
|
||||
let wordWidth = 0;
|
||||
for (const s of wordSyls) {
|
||||
const text = sylText(s);
|
||||
const w = ctx.measureText(text).width;
|
||||
parts.push({ syl: s, text, width: w });
|
||||
wordWidth += w;
|
||||
}
|
||||
const advance = wordWidth + spaceWidth;
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
|
||||
row.push({ parts, advance });
|
||||
rowWidth += advance;
|
||||
}
|
||||
const advance = wordWidth + spaceWidth;
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
|
||||
row.push({ parts, advance });
|
||||
rowWidth += advance;
|
||||
if (row.length) rows.push(row);
|
||||
}
|
||||
if (row.length) rows.push(row);
|
||||
|
||||
bgWidth = 0;
|
||||
for (const row of rows) {
|
||||
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
if (rw > bgWidth) bgWidth = rw;
|
||||
}
|
||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||
_lyrRowsCache = {
|
||||
lyricsRef: lyrics, idx: currentIdx,
|
||||
shown: linesToShow.length, fontSize, W,
|
||||
rows, spaceWidth, bgWidth,
|
||||
};
|
||||
}
|
||||
|
||||
const rowHeight = fontSize + 6;
|
||||
const totalHeight = rows.length * rowHeight + 10;
|
||||
let bgWidth = 0;
|
||||
for (const row of rows) {
|
||||
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
if (rw > bgWidth) bgWidth = rw;
|
||||
}
|
||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.beginPath();
|
||||
@@ -6629,7 +6689,7 @@
|
||||
depthWrite: false,
|
||||
depthTest: true,
|
||||
blending: T.AdditiveBlending,
|
||||
side: T.DoubleSide,
|
||||
side: T.DoubleSide, forceSinglePass: true,
|
||||
fog: true,
|
||||
}));
|
||||
mAccentHaloNear = mkAccentHaloMats(ACCENT_HALO_OP_NEAR);
|
||||
@@ -6689,7 +6749,7 @@
|
||||
new T.MeshBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true, opacity: 1.0, depthWrite: false,
|
||||
blending: T.AdditiveBlending, side: T.DoubleSide, fog: false,
|
||||
blending: T.AdditiveBlending, side: T.DoubleSide, forceSinglePass: true, fog: false,
|
||||
}),
|
||||
));
|
||||
// Notedetect feedback outline (issue #9): hot magenta-red (0xff0066, hue
|
||||
@@ -6829,7 +6889,7 @@
|
||||
emissiveIntensity: 0.9,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: T.DoubleSide,
|
||||
side: T.DoubleSide, forceSinglePass: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
});
|
||||
@@ -6856,7 +6916,7 @@
|
||||
color: CHORD_BOX_TEAL_HEX,
|
||||
transparent: true, opacity: 0.85,
|
||||
depthTest: false, depthWrite: false,
|
||||
fog: false, side: T.DoubleSide,
|
||||
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
pSusRail = pool(noteG, () => {
|
||||
const m = new T.Mesh(gSusRail, mSusRailBase.clone());
|
||||
@@ -6877,7 +6937,7 @@
|
||||
transparent: true, opacity: 0.55,
|
||||
blending: T.AdditiveBlending,
|
||||
depthTest: false, depthWrite: false,
|
||||
fog: false, side: T.DoubleSide,
|
||||
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
pSusRailBloom = pool(noteG, () => {
|
||||
const m = new T.Mesh(gSusRailBloom, mSusRailBloomBase.clone());
|
||||
@@ -6891,7 +6951,7 @@
|
||||
gTechPlane = new T.PlaneGeometry(1, 1);
|
||||
pTechPlane = pool(noteG, () => {
|
||||
const m = new T.Mesh(gTechPlane, new T.MeshBasicMaterial({
|
||||
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
|
||||
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
}));
|
||||
m.renderOrder = 1000;
|
||||
return m;
|
||||
@@ -6945,7 +7005,7 @@
|
||||
uniforms: { map: { value: spriteMat.map } },
|
||||
vertexShader: _imTechVert,
|
||||
fragmentShader: _imTechFrag,
|
||||
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
|
||||
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
const im = new T.InstancedMesh(geo, mat, IM_TECH_CAP);
|
||||
im.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||
@@ -7058,7 +7118,7 @@
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
side: T.DoubleSide,
|
||||
side: T.DoubleSide, forceSinglePass: true,
|
||||
}),
|
||||
));
|
||||
pChordBox = pool(noteG, () => new T.Mesh(
|
||||
@@ -7070,7 +7130,7 @@
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
side: T.DoubleSide,
|
||||
side: T.DoubleSide, forceSinglePass: true,
|
||||
}),
|
||||
));
|
||||
|
||||
@@ -7153,7 +7213,7 @@
|
||||
_imPMXFillMat = new T.ShaderMaterial({
|
||||
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
|
||||
transparent: true, depthTest: false, depthWrite: false,
|
||||
fog: false, side: T.DoubleSide,
|
||||
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
imPMXFill = new T.InstancedMesh(gPMXFill, _imPMXFillMat, IM_STRUM_CAP);
|
||||
imPMXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||
@@ -7233,7 +7293,7 @@
|
||||
_imFHXFillMat = new T.ShaderMaterial({
|
||||
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
|
||||
transparent: true, depthTest: false, depthWrite: false,
|
||||
fog: false, side: T.DoubleSide,
|
||||
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
imFHXFill = new T.InstancedMesh(gFHXFill, _imFHXFillMat, IM_STRUM_CAP);
|
||||
imFHXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||
@@ -7314,7 +7374,7 @@
|
||||
_imPMXLinesMat = new T.ShaderMaterial({
|
||||
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
|
||||
transparent: true, depthTest: false, depthWrite: false,
|
||||
fog: false, side: T.DoubleSide,
|
||||
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
imPMXLines = new T.InstancedMesh(gPMXLines, _imPMXLinesMat, IM_STRUM_CAP);
|
||||
imPMXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||
@@ -7396,7 +7456,7 @@
|
||||
_imFHXLinesMat = new T.ShaderMaterial({
|
||||
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
|
||||
transparent: true, depthTest: false, depthWrite: false,
|
||||
fog: false, side: T.DoubleSide,
|
||||
fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
imFHXLines = new T.InstancedMesh(gFHXLines, _imFHXLinesMat, IM_STRUM_CAP);
|
||||
imFHXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
|
||||
@@ -7418,28 +7478,28 @@
|
||||
gPMXFill,
|
||||
new T.MeshBasicMaterial({
|
||||
color: 0x000000, transparent: true, opacity: 1,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
}),
|
||||
));
|
||||
pFHXFill = pool(noteG, () => new T.Mesh(
|
||||
gFHXFill,
|
||||
new T.MeshBasicMaterial({
|
||||
color: 0x000000, transparent: true, opacity: 1,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
}),
|
||||
));
|
||||
pMuteXLines = pool(noteG, () => new T.Mesh(
|
||||
gPMXLines,
|
||||
new T.MeshBasicMaterial({
|
||||
color: 0xffffff, transparent: true, opacity: 1,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
}),
|
||||
));
|
||||
pFHXLines = pool(noteG, () => new T.Mesh(
|
||||
gFHXLines,
|
||||
new T.MeshBasicMaterial({
|
||||
color: 0xffffff, transparent: true, opacity: 1,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
|
||||
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
|
||||
}),
|
||||
));
|
||||
|
||||
@@ -9886,6 +9946,105 @@
|
||||
}
|
||||
|
||||
/* ── Per-frame rendering ─────────────────────────────────────────── */
|
||||
// ── GPU pre-warm (perf: first-appearance hitches) ─────────────────
|
||||
// Three.js compiles a material's shader program and uploads a
|
||||
// texture the first frame the owning object renders — profiled as
|
||||
// mid-song frame spikes (getParameters / texSubImage2D). Pay those
|
||||
// costs during init (load spinner) instead:
|
||||
// _prewarmStatic() — ren.compile() over the fully-built scene
|
||||
// + deterministic label textures (fret
|
||||
// numbers in every per-frame style/colour
|
||||
// combo).
|
||||
// _prewarmChart(bundle) — chart-dependent labels (chord template
|
||||
// names, section names); needs the ready
|
||||
// bundle, so it runs once from the first
|
||||
// draw() after each init.
|
||||
// txtMat() rasterises into the unbounded cache these draws hit
|
||||
// anyway; ren.initTexture() forces the GPU upload now.
|
||||
// Swap a pooled label sprite's cached texture WITHOUT recompiling.
|
||||
// Setting material.needsUpdate bumps material.version, which forces
|
||||
// Three.js through getParameters/getProgramCacheKey on the next
|
||||
// render. Swapping one non-null texture for another does NOT change
|
||||
// the compiled program (the USE_MAP define is unchanged); only a
|
||||
// null <-> non-null transition does, and pooled label sprites are
|
||||
// constructed with a non-null map, so in practice this never
|
||||
// recompiles. (Note: the DOMINANT getParameters churn turned out to
|
||||
// be Three's transparent-DoubleSide two-pass path — see the
|
||||
// forceSinglePass comment in _spriteMat2MeshMat — this helper
|
||||
// removes the label-swap contribution on top of that.)
|
||||
function _setLabelMap(sprite, srcMat) {
|
||||
const m = sprite.material;
|
||||
if (m.map === srcMat.map) return;
|
||||
const nullnessChanged = (m.map == null) !== (srcMat.map == null);
|
||||
m.map = srcMat.map;
|
||||
if (nullnessChanged) m.needsUpdate = true;
|
||||
}
|
||||
|
||||
let _chartPrewarmed = false;
|
||||
function _prewarmTex(mat) {
|
||||
if (mat && mat.map && ren) ren.initTexture(mat.map);
|
||||
}
|
||||
function _prewarmStatic() {
|
||||
// MAINTENANCE NOTE: this list must cover every deterministic
|
||||
// (chart-independent) material/texture the per-frame paths can
|
||||
// request lazily. Adding a new label style or sprite factory to
|
||||
// drawNote()/update() without warming it here silently
|
||||
// reintroduces a first-appearance texSubImage2D/compile spike
|
||||
// mid-song. Chart-dependent labels (chord names, section names)
|
||||
// live in _prewarmChart.
|
||||
try {
|
||||
if (ren && scene && cam) ren.compile(scene, cam);
|
||||
} catch (e) { console.warn('[3D-Hwy] prewarm compile:', e); }
|
||||
try {
|
||||
// Fret-number labels in the per-frame style/colour combos.
|
||||
for (let f = 0; f <= NFRETS; f++) {
|
||||
_prewarmTex(txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret'));
|
||||
_prewarmTex(txtMat(f, FRET_LABEL_GOLD_HEX, false, 'fretRow'));
|
||||
_prewarmTex(txtMat(f, FRET_LABEL_IDLE_HEX, false, 'fretRow'));
|
||||
_prewarmTex(txtMat(f, '#ffffff', false, 'ghostFret'));
|
||||
}
|
||||
// Teaching marks (drawNote _drawTeachMark): finger hints
|
||||
// T/1-4 (teachFg) and scale degrees 0-11 (teachSd).
|
||||
_prewarmTex(txtMat('T', '#7fd1ff', false, 'teachFg'));
|
||||
for (let i = 1; i <= 4; i++) _prewarmTex(txtMat(String(i), '#7fd1ff', false, 'teachFg'));
|
||||
for (let i = 0; i <= 11; i++) _prewarmTex(txtMat(String(i), '#ffcc66', false, 'teachSd'));
|
||||
// Technique sprite factories (own caches, keyed by packed
|
||||
// number): PM/FH mute X, hammer/pull triangles, bend
|
||||
// chevron stacks, slide direction arrows — per string
|
||||
// colour of the active palette.
|
||||
_prewarmTex(palmMuteXSpriteMat());
|
||||
_prewarmTex(fretHandMuteXSpriteMat());
|
||||
const _nWarm = Math.min(
|
||||
Math.max(nStr, 6),
|
||||
(activePalette && activePalette.length) || 0);
|
||||
for (let s = 0; s < _nWarm; s++) {
|
||||
const hex = activePalette[s] || 0xffffff;
|
||||
_prewarmTex(triMat(true, hex));
|
||||
_prewarmTex(triMat(false, hex));
|
||||
for (let st = 1; st <= 4; st++) _prewarmTex(bendChevronMat(st, hex));
|
||||
const arrowHex = darkenHex(hex, 0.55);
|
||||
_prewarmTex(slideArrowMat(true, arrowHex));
|
||||
_prewarmTex(slideArrowMat(false, arrowHex));
|
||||
}
|
||||
} catch (e) { console.warn('[3D-Hwy] prewarm labels:', e); }
|
||||
}
|
||||
function _prewarmChart(bundle) {
|
||||
try {
|
||||
const tpls = bundle && bundle.chordTemplates;
|
||||
if (Array.isArray(tpls)) {
|
||||
for (const tpl of tpls) {
|
||||
if (tpl && tpl.name) _prewarmTex(txtMat(tpl.name, '#e8d080', true, 'chord'));
|
||||
}
|
||||
}
|
||||
const secs = bundle && bundle.sections;
|
||||
if (Array.isArray(secs)) {
|
||||
for (const s of secs) {
|
||||
if (s && s.name) _prewarmTex(txtMat(s.name, '#00cccc', true, 'section'));
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[3D-Hwy] prewarm chart labels:', e); }
|
||||
}
|
||||
|
||||
function update(bundle) {
|
||||
pbBeg(0);
|
||||
// [verdict glow] Apply the level-driven verdict brightness captured
|
||||
@@ -9909,10 +10068,14 @@
|
||||
// Lean sustain rendering is the default (see declaration above):
|
||||
// the trail/ribbon outline always draws; only the additive rail
|
||||
// bloom halo is dropped. The full look (with bloom) is an opt-out.
|
||||
// Cheap per-frame read so the console flag takes effect live.
|
||||
try {
|
||||
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
|
||||
} catch (_) { _leanSus = true; }
|
||||
// localStorage.getItem is a synchronous storage read — polled at
|
||||
// ~1 Hz instead of every frame; the console flag still takes
|
||||
// effect live (within a second).
|
||||
if ((_leanSusPollCounter++ % 60) === 0) {
|
||||
try {
|
||||
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
|
||||
} catch (_) { _leanSus = true; }
|
||||
}
|
||||
// Materialize the text-size multiplier from the user's slider.
|
||||
// textSize ∈ [0,1]; _textSizeMul ∈ [0.5, 1.5] with 0.5 ↦ 1.0×
|
||||
// so default behaviour matches what the renderer did pre-slider.
|
||||
@@ -11856,7 +12019,7 @@
|
||||
const lblW = 28 * K, lblH = 9 * K;
|
||||
const lbl = pChordLbl.get();
|
||||
const mat = txtMat(chordName, '#e8d080', true, 'chord');
|
||||
if (lbl.material.map !== mat.map) { lbl.material.map = mat.map; lbl.material.needsUpdate = true; }
|
||||
_setLabelMap(lbl, mat);
|
||||
lbl.material.opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul;
|
||||
// Gold chord name: slight +X shift from flush-left so it sits farther right.
|
||||
const lblWS = lblW * _textSizeMul;
|
||||
@@ -11892,7 +12055,7 @@
|
||||
if (!text) return;
|
||||
const s = pChordLbl.get();
|
||||
const m = txtMat(text, colorHex, true, 'chord');
|
||||
if (s.material.map !== m.map) { s.material.map = m.map; s.material.needsUpdate = true; }
|
||||
_setLabelMap(s, m);
|
||||
s.material.opacity = opacity;
|
||||
s.position.set(baseX, hy, z);
|
||||
s.scale.set(hlW, hlH, 1);
|
||||
@@ -12016,10 +12179,7 @@
|
||||
_seenChordFrets.add(f);
|
||||
const lbl = pNoteFretLabel.get();
|
||||
const mat = txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
||||
if (lbl.material.map !== mat.map) {
|
||||
lbl.material.map = mat.map;
|
||||
lbl.material.needsUpdate = true;
|
||||
}
|
||||
_setLabelMap(lbl, mat);
|
||||
lbl.position.set(xFretMid(f), yMinF, z);
|
||||
lbl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRET_LABEL');
|
||||
const _flS = 7.0 * K * (1 + 0.4 * chDt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f);
|
||||
@@ -12642,10 +12802,7 @@
|
||||
const color = '#888888';
|
||||
const sp = pFretColMarker.get();
|
||||
const m = txtMat(f, color, false, 'noteFret');
|
||||
if (sp.material.map !== m.map) {
|
||||
sp.material.map = m.map;
|
||||
sp.material.needsUpdate = true;
|
||||
}
|
||||
_setLabelMap(sp, m);
|
||||
sp.material.opacity = 0.85 * _colFadeIn;
|
||||
sp.position.set(xFretMid(f), labelY, z);
|
||||
// Z-proportional: sits between chord frame and note gem
|
||||
@@ -13928,10 +14085,7 @@
|
||||
_frameLabeledKeys.add(_flFrameKey);
|
||||
const fretLabel = pNoteFretLabel.get();
|
||||
const cachedMat = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
||||
if (fretLabel.material.map !== cachedMat.map) {
|
||||
fretLabel.material.map = cachedMat.map;
|
||||
fretLabel.material.needsUpdate = true;
|
||||
}
|
||||
_setLabelMap(fretLabel, cachedMat);
|
||||
fretLabel.position.set(x, labelY, noteZ);
|
||||
fretLabel.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||
_isArpNote
|
||||
@@ -13956,10 +14110,7 @@
|
||||
if (!text) return;
|
||||
const spr = pTeachMarkLbl.get();
|
||||
const m = txtMat(text, colorHex, false, cacheKey);
|
||||
if (spr.material.map !== m.map) {
|
||||
spr.material.map = m.map;
|
||||
spr.material.needsUpdate = true;
|
||||
}
|
||||
_setLabelMap(spr, m);
|
||||
spr.position.set(x + dx, labelY, noteZ);
|
||||
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
|
||||
@@ -13993,10 +14144,7 @@
|
||||
const _isArp2 = arpBounds !== null;
|
||||
const fl2 = pNoteFretLabel.get();
|
||||
const cm2 = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
|
||||
if (fl2.material.map !== cm2.map) {
|
||||
fl2.material.map = cm2.map;
|
||||
fl2.material.needsUpdate = true;
|
||||
}
|
||||
_setLabelMap(fl2, cm2);
|
||||
fl2.position.set(x, _labelY2, noteZ);
|
||||
fl2.renderOrder = renderOrderForLayerAtZ(noteZ,
|
||||
_isArp2
|
||||
@@ -14962,6 +15110,12 @@
|
||||
_invertedForBoard = _invertedCached;
|
||||
_leftyForBoard = _leftyCached;
|
||||
if (!initScene()) { _unsubscribeFocus(); _rejectReady(new Error('initScene failed')); return; }
|
||||
// Pre-compile shaders + upload deterministic label
|
||||
// textures while the load spinner is still up; the
|
||||
// chart-dependent half runs on first draw() (bundle
|
||||
// arrays are only guaranteed populated post-ready).
|
||||
_prewarmStatic();
|
||||
_chartPrewarmed = false;
|
||||
const sz = canvasSize(highwayCanvas);
|
||||
// Mark ready before RAF so any resize(w,h) calls that arrive
|
||||
// in the meantime (e.g. from sizeCanvases()) are applied directly.
|
||||
@@ -15000,6 +15154,10 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (!_chartPrewarmed) {
|
||||
_chartPrewarmed = true;
|
||||
_prewarmChart(bundle);
|
||||
}
|
||||
_invertedCached = !!bundle.inverted;
|
||||
_leftyCached = !!bundle.lefty;
|
||||
const newNStr = resolveStringCount(bundle);
|
||||
@@ -15042,25 +15200,40 @@
|
||||
// for the pre-settle (too-tall) size and crops the near strings
|
||||
// / fret numbers until the user un/re-maximizes the window.
|
||||
if (highwayCanvas) {
|
||||
const box = canvasSize(highwayCanvas);
|
||||
if (highwayCanvas.width !== _lastHwW || highwayCanvas.height !== _lastHwH) {
|
||||
_lastHwW = highwayCanvas.width;
|
||||
_lastHwH = highwayCanvas.height;
|
||||
if (box.w > 0 && box.h > 0) applySize(box.w, box.h);
|
||||
} else if (box.w > 0 && box.h > 0 &&
|
||||
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
|
||||
applySize(box.w, box.h);
|
||||
} else if (!_wrapPinned && box.w > 0 && box.h > 0 &&
|
||||
highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
|
||||
// 3. The overlay pin couldn't be applied at init because
|
||||
// #highway had no layout yet (offsetWidth/Height === 0),
|
||||
// so applySize() only set the wrap height. The canvas has
|
||||
// now laid out but to the same logical size, so neither
|
||||
// drift branch above fires — re-run applySize to pin the
|
||||
// wrap to the canvas box now that its offsets are real.
|
||||
// Otherwise the overlay stays at top:0;left:0;right:0 and
|
||||
// a strip of #highway is exposed on first load / split.
|
||||
applySize(box.w, box.h);
|
||||
// Backing-store drift (branch 1) is detected with cheap
|
||||
// property reads every frame. The CSS-box checks (branches
|
||||
// 2/3) need canvasSize() → getBoundingClientRect(), a
|
||||
// forced layout read — profiled at ~1.2% of throttled
|
||||
// main-thread time when run per frame. Throttle the box
|
||||
// read to every 10th frame (plus whenever the backing
|
||||
// store changed or the wrap isn't pinned yet): the layout
|
||||
// settle it exists to catch plays out over hundreds of ms
|
||||
// right after a song opens, so a ~166 ms detection cadence
|
||||
// loses nothing visible.
|
||||
const _bsChanged = highwayCanvas.width !== _lastHwW
|
||||
|| highwayCanvas.height !== _lastHwH;
|
||||
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
|
||||
if (_bsChanged || !_wrapPinned || _boxCheckCountdown === 0) {
|
||||
const box = canvasSize(highwayCanvas);
|
||||
if (_bsChanged) {
|
||||
_lastHwW = highwayCanvas.width;
|
||||
_lastHwH = highwayCanvas.height;
|
||||
if (box.w > 0 && box.h > 0) applySize(box.w, box.h);
|
||||
} else if (box.w > 0 && box.h > 0 &&
|
||||
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
|
||||
applySize(box.w, box.h);
|
||||
} else if (!_wrapPinned && box.w > 0 && box.h > 0 &&
|
||||
highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
|
||||
// 3. The overlay pin couldn't be applied at init because
|
||||
// #highway had no layout yet (offsetWidth/Height === 0),
|
||||
// so applySize() only set the wrap height. The canvas has
|
||||
// now laid out but to the same logical size, so neither
|
||||
// drift branch above fires — re-run applySize to pin the
|
||||
// wrap to the canvas box now that its offsets are real.
|
||||
// Otherwise the overlay stays at top:0;left:0;right:0 and
|
||||
// a strip of #highway is exposed on first load / split.
|
||||
applySize(box.w, box.h);
|
||||
}
|
||||
}
|
||||
}
|
||||
update(bundle);
|
||||
|
||||
@@ -31,6 +31,23 @@ tunes one of these, mirror the change here (and in `drum_highway_3d`):
|
||||
mapping switch in `draw()`; addons dynamic-imported from
|
||||
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
||||
graceful degrade)
|
||||
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
|
||||
(pool 96 here — the flame sprites carry most of the hit feedback)
|
||||
- `_timingHex()` / `_classifyTiming()` — early/late/on-time feedback
|
||||
colors (green/cyan/amber) + the 40%-window classifier
|
||||
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
|
||||
checks, which it needs for input routing and we don't)
|
||||
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
|
||||
as the guitar's, except `default` which is this plugin's original
|
||||
palette); one pick drives background gradient + floor + lane rails
|
||||
- `_makeStudioEnv()` — procedural PMREM studio environment (shared with
|
||||
drum_highway_3d; RoomEnvironment isn't vendored)
|
||||
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
|
||||
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
|
||||
`_bgReadBands()` — background ambience + the stems-first audio-analyser
|
||||
bridge (shared with drum_highway_3d)
|
||||
- `_drawScoreFx()` — score overlay (pops / tier rings / milestone bursts /
|
||||
streak-break wash), drum_highway_3d pattern
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,88 @@
|
||||
<!-- Graphics -->
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
|
||||
<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)"
|
||||
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="default">Default (original)</option>
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="charcoal">Charcoal</option>
|
||||
<option value="deeppurple">Deep Purple</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="warmslate">Warm Slate</option>
|
||||
<option value="deepfocus">Deep Focus</option>
|
||||
<option value="deepsea">Deep Sea</option>
|
||||
<option value="cathode">Cathode</option>
|
||||
<option value="cathodegreen">Cathode Green</option>
|
||||
<option value="hearth">Hearth</option>
|
||||
</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.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="keysh3d-fx-cinematic" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
|
||||
Cinematic lighting
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Dimmer ambience, stronger key light — deeper shading on the keys
|
||||
and gems.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Glow strength <span id="keysh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-glow"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('glow', this.value); document.getElementById('keysh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How hard the notes, key glow and sustain consume-flash
|
||||
self-illuminate (0.5 = stock). Pairs with bloom.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
|
||||
<select id="keysh3d-fx-bgstyle"
|
||||
onchange="window.keys3dSetBgStyle && window.keys3dSetBgStyle(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="off">Off</option>
|
||||
<option value="particles" selected>Particles</option>
|
||||
<option value="lights">Stage lights</option>
|
||||
<option value="geometric">Geometric</option>
|
||||
</select>
|
||||
<label for="keysh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
|
||||
Ambience intensity <span id="keysh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-bgintensity"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('bgIntensity', this.value); document.getElementById('keysh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<label for="keysh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
|
||||
<input type="checkbox" id="keysh3d-fx-bgreactive" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('bgReactive', this.checked)">
|
||||
Audio-reactive ambience
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
The backdrop pulses with the mix (stems analyser when a sloppak
|
||||
is loaded). Off = it animates on time only.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-scorefx" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('scoreFx', this.checked)">
|
||||
Score effects
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
+1 pops on scored presses, a ring pulse every 10-combo,
|
||||
milestone bursts at 25/50/100, and a brief red flicker when a
|
||||
streak breaks.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-bloom" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('bloom', this.checked)">
|
||||
Glow (bloom)
|
||||
@@ -21,6 +102,59 @@
|
||||
sustains. Applies live; turn off to reclaim GPU headroom on
|
||||
weak machines.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-sparks" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('sparks', this.checked)">
|
||||
Hit sparks
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
A small particle burst on every scored key press, beside the
|
||||
pitch-colored flame.
|
||||
</p>
|
||||
|
||||
<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
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint the sparks by timing — on-time green, early cyan, late
|
||||
amber. Off = always green.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-streak" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('streakFx', this.checked)">
|
||||
Streak feedback
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Spark bursts grow with your combo.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Hit feedback intensity <span id="keysh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-hitfx"
|
||||
min="0" max="1" step="0.05" value="0.7"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('hitFx', this.value); document.getElementById('keysh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Drives the hit-line brightness kick on scored presses. 0 turns
|
||||
it off.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Note vibrancy <span id="keysh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-vibrancy"
|
||||
min="0" max="1" step="0.05" value="0.85"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('vibrancy', this.value); document.getElementById('keysh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Note-gem solidity and lane-guide strength — lower to see more of
|
||||
the keyboard through the notes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -33,11 +167,38 @@
|
||||
// FX toggles (keys3d_bg_* — guitar-parity graphics controls).
|
||||
// Only explicit values override; absent/corrupt keys keep the
|
||||
// default (ON), matching screen.js readFxSettings.
|
||||
const storedBloom = localStorage.getItem('keys3d_bg_bloom');
|
||||
if (storedBloom === '1' || storedBloom === 'true') {
|
||||
document.getElementById('keysh3d-fx-bloom').checked = true;
|
||||
} else if (storedBloom === '0' || storedBloom === 'false') {
|
||||
document.getElementById('keysh3d-fx-bloom').checked = false;
|
||||
const hydrateFxBool = (key, elId) => {
|
||||
const raw = localStorage.getItem('keys3d_bg_' + key);
|
||||
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
|
||||
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
|
||||
};
|
||||
hydrateFxBool('bloom', 'keysh3d-fx-bloom');
|
||||
hydrateFxBool('sparks', 'keysh3d-fx-sparks');
|
||||
hydrateFxBool('timingFx', 'keysh3d-fx-timing');
|
||||
hydrateFxBool('streakFx', 'keysh3d-fx-streak');
|
||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const v = Math.min(1, Math.max(0, n));
|
||||
document.getElementById(elId).value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRange('hitFx', 'keysh3d-fx-hitfx', 'keysh3d-fx-hitfx-val');
|
||||
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');
|
||||
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)) {
|
||||
styleSel.value = storedStyle;
|
||||
}
|
||||
const storedTheme = localStorage.getItem('keys3d_bg_theme');
|
||||
const themeSel = document.getElementById('keysh3d-fx-theme');
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
|
||||
@@ -80,3 +80,71 @@ test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
|
||||
assert.equal(events.length, 4);
|
||||
assert.ok(!('keys3d_bg_nonsense' in store));
|
||||
});
|
||||
|
||||
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
|
||||
const { _classifyTiming } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const tol = 0.10; // keys HIT_TOLERANCE_S
|
||||
assert.equal(_classifyTiming(0, tol), 'OK');
|
||||
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK');
|
||||
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
|
||||
// delta = note.t - now: positive → struck before the note → EARLY.
|
||||
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
|
||||
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
|
||||
assert.equal(_classifyTiming(NaN, tol), 'OK');
|
||||
});
|
||||
|
||||
test('noteKey prefix round-trips the matched note time (timing-delta source)', () => {
|
||||
const { noteKey } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
// _checkHit derives the timing delta as parseFloat(judgeHit's key) - t;
|
||||
// this pins the serialization that makes that recovery valid.
|
||||
assert.equal(parseFloat(noteKey(12.3456, 60)), 12.346);
|
||||
assert.equal(parseFloat(noteKey(0, 21)), 0);
|
||||
});
|
||||
|
||||
test('FX defaults: hit-FX + vibrancy controls ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.sparks, true);
|
||||
assert.equal(FX_DEFAULTS.timingFx, true);
|
||||
assert.equal(FX_DEFAULTS.streakFx, true);
|
||||
assert.equal(FX_DEFAULTS.hitFx, 0.7);
|
||||
assert.equal(FX_DEFAULTS.vibrancy, 0.85);
|
||||
});
|
||||
|
||||
test('themes: table ids match the guitar highway, default is the stock palette', () => {
|
||||
const { BG_THEMES, _bgThemeColors, readThemeSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(BG_THEMES), [
|
||||
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
|
||||
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
|
||||
]);
|
||||
// 'default' preserves THIS plugin's original look.
|
||||
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
|
||||
assert.equal(BG_THEMES.default.board, 0x141422);
|
||||
assert.equal(BG_THEMES.default.laneDim, 0x2a2a3e);
|
||||
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
|
||||
assert.equal(readThemeSetting(), 'default'); // no localStorage in the vm
|
||||
for (const [id, t] of Object.entries(BG_THEMES)) {
|
||||
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
|
||||
assert.ok(t.laneDim != null, id + ' rail color');
|
||||
}
|
||||
});
|
||||
|
||||
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.cinematic, true);
|
||||
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
|
||||
});
|
||||
|
||||
|
||||
test('bg styles: validated id set, particles default', () => {
|
||||
const { BG_STYLE_IDS, readBgStyleSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
|
||||
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
|
||||
assert.equal(readBgStyleSetting(), 'particles');
|
||||
});
|
||||
|
||||
test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.scoreFx, true);
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
+4
-14
@@ -33,8 +33,6 @@ def setup(app: FastAPI, context: dict):
|
||||
"lastInstrument": DEFAULT_INSTRUMENT,
|
||||
"freeTune": False,
|
||||
"customTunings": {},
|
||||
"disabledTunings": [],
|
||||
"showFloatingButton": True,
|
||||
"visualizationMode": "default",
|
||||
"audioInputMode": "auto",
|
||||
"autoOpenOnTuningChange": False,
|
||||
@@ -51,8 +49,6 @@ def setup(app: FastAPI, context: dict):
|
||||
res["lastInstrument"] = str(data.get("lastInstrument", DEFAULT_INSTRUMENT))
|
||||
res["freeTune"] = bool(data.get("freeTune", False))
|
||||
res["customTunings"] = data.get("customTunings", {})
|
||||
res["disabledTunings"] = data.get("disabledTunings", [])
|
||||
res["showFloatingButton"] = bool(data.get("showFloatingButton", True))
|
||||
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
|
||||
raw_mode = str(data.get("audioInputMode", "auto"))
|
||||
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
|
||||
@@ -64,8 +60,6 @@ def setup(app: FastAPI, context: dict):
|
||||
|
||||
if not isinstance(res["customTunings"], dict):
|
||||
res["customTunings"] = {}
|
||||
if not isinstance(res["disabledTunings"], list):
|
||||
res["disabledTunings"] = []
|
||||
|
||||
# Migrate custom tunings from old flat-list format
|
||||
res["customTunings"] = {
|
||||
@@ -73,12 +67,6 @@ def setup(app: FastAPI, context: dict):
|
||||
for name, val in res["customTunings"].items()
|
||||
}
|
||||
|
||||
# Strip legacy disabledTunings entries that lack compound "instrument:name" format
|
||||
res["disabledTunings"] = [
|
||||
e for e in res["disabledTunings"]
|
||||
if isinstance(e, str) and ":" in e
|
||||
]
|
||||
|
||||
return res
|
||||
except Exception:
|
||||
return defaults
|
||||
@@ -86,8 +74,10 @@ def setup(app: FastAPI, context: dict):
|
||||
def _write(data: dict) -> None:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
current = _read()
|
||||
# Strip keys that belong to core, not to this plugin's config.
|
||||
for key in ("defaultTunings", "referencePitch"):
|
||||
# Strip keys that belong to core, not to this plugin's config, plus
|
||||
# retired keys (disabledTunings/showFloatingButton — their settings UI
|
||||
# was removed) so a stale client can't re-persist them.
|
||||
for key in ("defaultTunings", "referencePitch", "disabledTunings", "showFloatingButton"):
|
||||
data = {k: v for k, v in data.items() if k != key}
|
||||
current.update(data)
|
||||
config_file.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||
|
||||
+1
-11
@@ -42,7 +42,6 @@
|
||||
_allTunings: {},
|
||||
referencePitch: 440,
|
||||
visualizationMode: 'default',
|
||||
showFloatingButton: true,
|
||||
currentSongOffsets: null,
|
||||
currentSongIsBass: false,
|
||||
currentSongStringCount: 0,
|
||||
@@ -92,10 +91,6 @@
|
||||
}
|
||||
|
||||
// ── Tuning helpers ────────────────────────────────────────────────
|
||||
function _isTuningEnabled(instrument, name) {
|
||||
return !((_state._serverConfig ? _state._serverConfig.disabledTunings : null) || []).includes(instrument + ':' + name);
|
||||
}
|
||||
|
||||
function _instrumentForTuning(name) {
|
||||
for (var key in _state._allTunings) {
|
||||
if (_state._allTunings[key] && _state._allTunings[key][name]) return key;
|
||||
@@ -104,11 +99,7 @@
|
||||
}
|
||||
|
||||
function _buildTuningsForInstrument(instrument) {
|
||||
const all = _state._allTunings[instrument] || {};
|
||||
const disabled = (_state._serverConfig ? _state._serverConfig.disabledTunings : null) || [];
|
||||
return Object.fromEntries(
|
||||
Object.entries(all).filter(([name]) => !disabled.includes(instrument + ':' + name))
|
||||
);
|
||||
return { ...(_state._allTunings[instrument] || {}) };
|
||||
}
|
||||
|
||||
function _tuningIdentityKey(songInfo) {
|
||||
@@ -554,7 +545,6 @@
|
||||
_state._serverConfig = config;
|
||||
_state._allTunings = tuningsData.tunings || {};
|
||||
_state.referencePitch = tuningsData.referencePitch || 440;
|
||||
_state.showFloatingButton = config.showFloatingButton !== false;
|
||||
_state.visualizationMode = config.visualizationMode || 'default';
|
||||
_state.audioInputMode = config.audioInputMode || 'auto';
|
||||
|
||||
|
||||
+1
-146
@@ -1,15 +1,4 @@
|
||||
<div class="space-y-6 py-2">
|
||||
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-200">Floating Button</h3>
|
||||
<p class="text-[11px] text-gray-500">Show the tuner button on the main interface.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="tuner-show-floating" class="sr-only peer" onchange="window._tunerToggleFloating(this.checked)">
|
||||
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-accent"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-200">Auto-open on tuning change</h3>
|
||||
@@ -38,14 +27,6 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Tuning Visibility</h3>
|
||||
<p class="text-xs text-gray-500 mb-4">Toggle which built-in tunings appear in the tuner menu.</p>
|
||||
<div id="tuner-visibility-list" class="space-y-2 pr-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Custom Tunings</h3>
|
||||
<div id="tuner-custom-list" class="space-y-2 mb-4">
|
||||
<!-- Populated by JS -->
|
||||
@@ -88,16 +69,7 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
let config = { customTunings: {}, disabledTunings: [], defaultTunings: {}, showFloatingButton: true, audioInputMode: 'auto' };
|
||||
let expandedGroups = [];
|
||||
|
||||
var _INSTRUMENT_CAPTIONS = {
|
||||
"guitar-6": "Guitar",
|
||||
"guitar-7": "Guitar 7-string",
|
||||
"guitar-8": "Guitar 8-string",
|
||||
"bass-4": "Bass 4-string",
|
||||
"bass-5": "Bass 5-string"
|
||||
};
|
||||
let config = { customTunings: {}, audioInputMode: 'auto' };
|
||||
|
||||
var _instrumentLabels = {
|
||||
"guitar-6": "Guitar 6-string",
|
||||
@@ -112,9 +84,6 @@
|
||||
const resp = await fetch('/api/plugins/tuner/config');
|
||||
config = await resp.json();
|
||||
|
||||
const floatingToggle = document.getElementById('tuner-show-floating');
|
||||
if (floatingToggle) floatingToggle.checked = config.showFloatingButton !== false;
|
||||
|
||||
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
|
||||
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
|
||||
|
||||
@@ -125,11 +94,6 @@
|
||||
} catch (e) { console.error('Tuner settings: load failed', e); }
|
||||
}
|
||||
|
||||
window._tunerToggleFloating = (enabled) => {
|
||||
config.showFloatingButton = enabled;
|
||||
save();
|
||||
};
|
||||
|
||||
window._tunerToggleBrowserAudio = (forceBrowser) => {
|
||||
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
|
||||
save();
|
||||
@@ -153,115 +117,6 @@
|
||||
}
|
||||
|
||||
function render() {
|
||||
const visList = document.getElementById('tuner-visibility-list');
|
||||
visList.innerHTML = '';
|
||||
|
||||
const defaultTunings = config.defaultTunings || {};
|
||||
|
||||
Object.keys(defaultTunings).forEach(groupName => {
|
||||
const group = defaultTunings[groupName];
|
||||
const groupTunings = Object.keys(group);
|
||||
const instrument = groupName;
|
||||
// Compound keys for all tunings in this group
|
||||
const compoundKeys = groupTunings.map(n => instrument + ':' + n);
|
||||
|
||||
const groupWrapper = document.createElement('div');
|
||||
groupWrapper.className = 'mb-4';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-center justify-between p-2 mt-2 bg-dark-900/80 rounded-t-lg border-x border-t border-gray-800/50 cursor-pointer hover:bg-dark-900 transition-colors';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'flex items-center gap-2';
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>';
|
||||
chevron.className = 'text-gray-500 transition-transform duration-200 rotate-0';
|
||||
|
||||
const groupLabel = document.createElement('span');
|
||||
groupLabel.className = 'text-[11px] font-bold text-gray-400 uppercase tracking-wider';
|
||||
groupLabel.textContent = _INSTRUMENT_CAPTIONS[groupName] || groupName;
|
||||
|
||||
left.appendChild(chevron);
|
||||
left.appendChild(groupLabel);
|
||||
|
||||
const groupToggle = document.createElement('input');
|
||||
groupToggle.type = 'checkbox';
|
||||
const allEnabled = compoundKeys.every(k => !config.disabledTunings.includes(k));
|
||||
const someEnabled = compoundKeys.some(k => !config.disabledTunings.includes(k));
|
||||
groupToggle.checked = allEnabled;
|
||||
groupToggle.indeterminate = someEnabled && !allEnabled;
|
||||
groupToggle.className = 'accent-accent';
|
||||
|
||||
groupToggle.onclick = (e) => e.stopPropagation();
|
||||
groupToggle.onchange = () => {
|
||||
if (groupToggle.checked) {
|
||||
config.disabledTunings = config.disabledTunings.filter(k => !compoundKeys.includes(k));
|
||||
} else {
|
||||
compoundKeys.forEach(k => {
|
||||
if (!config.disabledTunings.includes(k)) config.disabledTunings.push(k);
|
||||
});
|
||||
}
|
||||
save();
|
||||
render();
|
||||
};
|
||||
|
||||
header.appendChild(left);
|
||||
header.appendChild(groupToggle);
|
||||
groupWrapper.appendChild(header);
|
||||
|
||||
const groupContainer = document.createElement('div');
|
||||
groupContainer.className = 'border-x border-b border-gray-800/50 rounded-b-lg overflow-hidden';
|
||||
|
||||
const isExpanded = expandedGroups.includes(groupName);
|
||||
if (!isExpanded) {
|
||||
groupContainer.classList.add('hidden');
|
||||
chevron.classList.remove('rotate-90');
|
||||
} else {
|
||||
chevron.classList.add('rotate-90');
|
||||
}
|
||||
|
||||
header.onclick = () => {
|
||||
const idx = expandedGroups.indexOf(groupName);
|
||||
if (idx === -1) {
|
||||
expandedGroups.push(groupName);
|
||||
} else {
|
||||
expandedGroups.splice(idx, 1);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
groupTunings.forEach((name, idx) => {
|
||||
const compoundKey = instrument + ':' + name;
|
||||
const div = document.createElement('div');
|
||||
div.className = `flex items-center justify-between p-2 bg-dark-800/30 hover:bg-dark-800/50 transition-colors ${idx === 0 ? 'border-t-0' : 'border-t border-gray-800/20'}`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs text-gray-300';
|
||||
label.textContent = name;
|
||||
|
||||
const toggle = document.createElement('input');
|
||||
toggle.type = 'checkbox';
|
||||
toggle.checked = !config.disabledTunings.includes(compoundKey);
|
||||
toggle.className = 'accent-accent';
|
||||
toggle.onchange = () => {
|
||||
if (toggle.checked) {
|
||||
config.disabledTunings = config.disabledTunings.filter(k => k !== compoundKey);
|
||||
} else {
|
||||
if (!config.disabledTunings.includes(compoundKey)) config.disabledTunings.push(compoundKey);
|
||||
}
|
||||
save();
|
||||
render();
|
||||
};
|
||||
|
||||
div.appendChild(label);
|
||||
div.appendChild(toggle);
|
||||
groupContainer.appendChild(div);
|
||||
});
|
||||
groupWrapper.appendChild(groupContainer);
|
||||
visList.appendChild(groupWrapper);
|
||||
});
|
||||
|
||||
const customList = document.getElementById('tuner-custom-list');
|
||||
customList.innerHTML = '';
|
||||
const customNames = Object.keys(config.customTunings);
|
||||
|
||||
@@ -441,7 +441,7 @@ window._tunerUI = function(state, actions) {
|
||||
const btn = document.getElementById('tuner-toggle-btn');
|
||||
if (!btn) return;
|
||||
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
|
||||
if (!state.showFloatingButton || isPlayer || window.feedBack?.isPlaying) {
|
||||
if (isPlayer || window.feedBack?.isPlaying) {
|
||||
btn.classList.add('hidden');
|
||||
} else {
|
||||
btn.classList.remove('hidden');
|
||||
@@ -727,6 +727,33 @@ window._tunerUI = function(state, actions) {
|
||||
|
||||
document.body.appendChild(state.uiContainer);
|
||||
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// Re-anchor while the panel is open: the popover it hugs is
|
||||
// vertically centered, so its rect shifts with viewport height.
|
||||
// initUI() runs once (guarded above), so this binds a single listener.
|
||||
window.addEventListener('resize', () => {
|
||||
if (state.uiContainer && !state.uiContainer.classList.contains('hidden')) positionPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the sidebar Plugins popover to anchor the panel beside it.
|
||||
// Prefer the host's stable plugin-control slot API and derive its popover
|
||||
// container; fall back to the known popover id only if that's unavailable.
|
||||
// Returns a visible rect, or null (→ caller uses the fixed fallback slot).
|
||||
function _pluginsPopoverRect() {
|
||||
let pop = null;
|
||||
try {
|
||||
if (window.feedBack?.ui && typeof window.feedBack.ui.playerControlSlot === 'function') {
|
||||
const slot = window.feedBack.ui.playerControlSlot();
|
||||
if (slot instanceof Element) pop = slot.closest('.v3-rail-pop') || slot;
|
||||
}
|
||||
} catch (_e) { /* host slot API failure → fall back to id lookup */ }
|
||||
if (!pop) pop = document.getElementById('v3-rail-pop-plugins');
|
||||
// offsetParent === null covers display:none on the element or any
|
||||
// ancestor (more robust than testing a specific `hidden` class).
|
||||
if (!pop || pop.offsetParent === null) return null;
|
||||
const rect = pop.getBoundingClientRect();
|
||||
return (rect.width || rect.height) ? rect : null;
|
||||
}
|
||||
|
||||
function positionPanel() {
|
||||
@@ -746,7 +773,20 @@ window._tunerUI = function(state, actions) {
|
||||
.replace('right-0', '')
|
||||
.replace('top-full', '')
|
||||
.trim();
|
||||
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
|
||||
const anchor = _pluginsPopoverRect();
|
||||
if (anchor) {
|
||||
// Anchor to the right of the Plugins popover, clamped to the
|
||||
// viewport so the panel never opens off-screen (right/bottom)
|
||||
// on a narrow or short window.
|
||||
const GAP = 8, MARGIN = 8;
|
||||
const pw = state.uiContainer.offsetWidth || 288; // w-72
|
||||
const ph = state.uiContainer.offsetHeight || 0;
|
||||
const left = Math.max(MARGIN, Math.min(anchor.right + GAP, window.innerWidth - pw - MARGIN));
|
||||
const top = Math.max(MARGIN, Math.min(anchor.top, window.innerHeight - ph - MARGIN));
|
||||
state.uiContainer.style.cssText = `top:${top}px;left:${left}px`;
|
||||
} else {
|
||||
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+92
-24
@@ -2758,6 +2758,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;
|
||||
@@ -3410,6 +3416,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 +3909,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 {
|
||||
@@ -6195,7 +6215,7 @@ window.feedBack.on('song:ready', () => {
|
||||
setSpeed(pend.speed);
|
||||
}
|
||||
} catch (_) { /* speed restore is best-effort */ }
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
@@ -6761,7 +6781,16 @@ window.feedBack.playQueue = (function () {
|
||||
if (!files.length) return false;
|
||||
list = files.slice(); idx = 0;
|
||||
source = (opts && opts.source) || '';
|
||||
arrangements = (opts && opts.arrangements) || null;
|
||||
arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
|
||||
if (opts && opts.shuffle && list.length > 1) {
|
||||
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
|
||||
// album slot's pinned arrangement stays glued to its file (#685).
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
|
||||
}
|
||||
}
|
||||
if (window.fbNotify) {
|
||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||
}
|
||||
@@ -6778,6 +6807,14 @@ window.feedBack.playQueue = (function () {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
// screen's "Up next: … starting in 10s" strip) without reaching into
|
||||
// queue internals. Null when nothing follows.
|
||||
peekNext: function () {
|
||||
return hasNext()
|
||||
? { filename: list[idx + 1], index: idx + 1, total: list.length }
|
||||
: null;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -9777,6 +9814,12 @@ async function startSongCountIn() {
|
||||
|
||||
// Time display + highway sync
|
||||
let lastAudioTime = 0;
|
||||
// hud-time write cache: the 60 Hz tick below used to rewrite textContent
|
||||
// (and getElementById) every tick even though the mm:ss display only
|
||||
// changes once a second — each write invalidates layout. Write-on-change
|
||||
// with a cached element ref (re-resolved if detached).
|
||||
let _hudTimeEl = null;
|
||||
let _hudTimeLast = '';
|
||||
setInterval(() => {
|
||||
let ct = _audioTime();
|
||||
const dur = _audioDuration();
|
||||
@@ -9804,7 +9847,12 @@ setInterval(() => {
|
||||
ct = lastAudioTime;
|
||||
}
|
||||
lastAudioTime = ct;
|
||||
document.getElementById('hud-time').textContent = `${formatTime(ct)} / ${formatTime(dur)}`;
|
||||
const hudText = `${formatTime(ct)} / ${formatTime(dur)}`;
|
||||
if (hudText !== _hudTimeLast) {
|
||||
if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time');
|
||||
if (_hudTimeEl) _hudTimeEl.textContent = hudText;
|
||||
_hudTimeLast = hudText;
|
||||
}
|
||||
if (dur) {
|
||||
_maybeRefreshSectionPracticeDuration(dur);
|
||||
}
|
||||
@@ -10949,20 +10997,19 @@ async function loadPlugins() {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
for (const [pluginId, contributions] of _pluginUiContributions) {
|
||||
if (livePluginIds.has(pluginId)) continue;
|
||||
const stalePlugin = { id: pluginId };
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
|
||||
}
|
||||
try {
|
||||
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
|
||||
} catch (e) {
|
||||
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
|
||||
}
|
||||
_pluginUiContributions.delete(pluginId);
|
||||
}
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
@@ -11104,17 +11151,23 @@ async function loadPlugins() {
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins that vanished from /api/plugins or are
|
||||
// no longer ready+styled this round. _injectPluginStyles below only
|
||||
// visits plugins still returned by the API, so an uninstalled or
|
||||
// newly-not-ready plugin would otherwise keep its <link> applying.
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (!styled.has(id)) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
@@ -11127,6 +11180,18 @@ async function loadPlugins() {
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
@@ -11154,7 +11219,10 @@ async function loadPlugins() {
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
if (!pid || !alreadyHydrated.has(pid)) el.remove();
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
@@ -11163,7 +11231,7 @@ async function loadPlugins() {
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!alreadyHydrated.has(pid)) el.remove();
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Core interface-scale capability — the app-wide "Interface size" preference.
|
||||
//
|
||||
// Owns a single user setting: a multiplier applied to the ROOT font-size, so
|
||||
// the rem-based v3 chrome (menus, buttons, text, spacing) scales together. This
|
||||
// is the DOM lever — it deliberately does NOT touch the gameplay highway canvas
|
||||
// (which is sized in device pixels, not rem), so scaling the UI never changes
|
||||
// playback resolution or FPS.
|
||||
//
|
||||
// It is exposed as a host read/write API on `window.feedBack.scale` so surfaces
|
||||
// that can't inherit `rem` — canvas / WebGL renderers such as the note-highway
|
||||
// HUD or results scorecards — can read the number via `feedBack.scale.get()`
|
||||
// and follow `scale:changed`. Shape mirrors the other host capabilities (a
|
||||
// frozen, versioned object) and the working-tuning read-API: synchronous
|
||||
// `get()`, a `set()` mutator, and a change event that also fires once on load.
|
||||
//
|
||||
// The visual apply ALSO runs pre-paint from a tiny inline <head> script (see
|
||||
// index.html) so there is no flash-of-reflow on load; this module is the
|
||||
// authoritative owner and re-applies idempotently.
|
||||
(function () {
|
||||
'use strict';
|
||||
window.feedBack = window.feedBack || {};
|
||||
if (window.feedBack.scale && window.feedBack.scale.version === 1) return;
|
||||
|
||||
var STORE_KEY = 'v3-interface-scale';
|
||||
var MIN = 0.85, MAX = 1.50, DEFAULT = 1.0;
|
||||
// The named presets rendered by the Settings segmented control. Kept here so
|
||||
// the control and any consumer read the ladder from one source of truth.
|
||||
var PRESETS = [
|
||||
{ step: 'small', value: 0.90 },
|
||||
{ step: 'medium', value: 1.00 },
|
||||
{ step: 'large', value: 1.15 },
|
||||
{ step: 'x-large', value: 1.30 },
|
||||
];
|
||||
|
||||
function clamp(n) {
|
||||
n = Number(n);
|
||||
if (!isFinite(n)) return DEFAULT;
|
||||
return Math.min(MAX, Math.max(MIN, n));
|
||||
}
|
||||
|
||||
function stepFor(value) {
|
||||
for (var i = 0; i < PRESETS.length; i++) {
|
||||
if (Math.abs(PRESETS[i].value - value) < 0.001) return PRESETS[i].step;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORE_KEY);
|
||||
if (raw == null) return DEFAULT;
|
||||
return clamp(parseFloat(raw));
|
||||
} catch (_) { return DEFAULT; }
|
||||
}
|
||||
|
||||
var current = read();
|
||||
|
||||
// Apply to the DOM. The lever is a RELATIVE root font-size (a percentage of
|
||||
// the user-agent base), never a px literal — so a user who raised their
|
||||
// browser/OS base font size is respected, not silently overridden. We also
|
||||
// publish the always-present `--fb-scale` token for canvas consumers and CSS.
|
||||
function apply(value) {
|
||||
var el = document.documentElement;
|
||||
if (!el) return;
|
||||
el.style.setProperty('--fb-scale', String(value));
|
||||
// Medium (1.0) clears the inline override so default rendering is
|
||||
// byte-identical to before this feature existed (zero blast radius).
|
||||
el.style.fontSize = (Math.abs(value - 1) < 0.001) ? '' : (value * 100).toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function persist(value) {
|
||||
try {
|
||||
if (Math.abs(value - DEFAULT) < 0.001) localStorage.removeItem(STORE_KEY);
|
||||
else localStorage.setItem(STORE_KEY, String(value));
|
||||
} catch (_) { /* private mode */ }
|
||||
}
|
||||
|
||||
function announce() {
|
||||
try {
|
||||
if (typeof window.feedBack.emit === 'function') {
|
||||
window.feedBack.emit('scale:changed', { value: current, step: stepFor(current) });
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
// Hydrate on load (idempotent with the pre-paint inline script).
|
||||
apply(current);
|
||||
|
||||
window.feedBack.scale = Object.freeze({
|
||||
version: 1,
|
||||
min: MIN,
|
||||
max: MAX,
|
||||
default: DEFAULT,
|
||||
// Synchronous — valid immediately after this module parses.
|
||||
get: function () { return { value: current, step: stepFor(current) }; },
|
||||
// A copy of the preset ladder, so a UI can render it from one source.
|
||||
presets: function () {
|
||||
return PRESETS.map(function (p) { return { step: p.step, value: p.value }; });
|
||||
},
|
||||
// Set + apply + persist + announce. Pass { persist:false } for a
|
||||
// transient preview (e.g. a live slider drag) that shouldn't be written.
|
||||
set: function (value, opts) {
|
||||
var v = clamp(value);
|
||||
current = v;
|
||||
apply(v);
|
||||
if (!opts || opts.persist !== false) persist(v);
|
||||
announce();
|
||||
return current;
|
||||
},
|
||||
});
|
||||
|
||||
// Announce once after the document parses, so any listener wired during page
|
||||
// load can sync without special-casing (consumers may also just call get()).
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', announce, { once: true });
|
||||
} else {
|
||||
announce();
|
||||
}
|
||||
})();
|
||||
@@ -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); });
|
||||
|
||||
+169
-92
@@ -122,6 +122,24 @@ function createHighway() {
|
||||
// where offsetParent === null isn't enough.
|
||||
let _visibleOverride = null;
|
||||
let _lastVisible = null;
|
||||
// Throttled DOM visibility sampling. Reading canvas.offsetParent
|
||||
// every rAF frame forces a style/layout recalc — profiled at ~0.5 s
|
||||
// main-thread self-time over a 63 s session. The displayed state
|
||||
// changes rarely (navigate / splitscreen panel toggle), so the DOM
|
||||
// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached
|
||||
// value serves the frames in between (worst-case transition latency
|
||||
// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal).
|
||||
// Set _domVisSampledFrame to NaN to force a fresh sample on the next
|
||||
// check (done on init, canvas replace, resize, and override-clear so
|
||||
// deliberate transitions don't wait out the throttle window).
|
||||
// NOTE those manual resets are LATENCY optimizations, not correctness
|
||||
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
|
||||
// frames regardless, so a visibility-affecting path that forgets to
|
||||
// reset self-heals within ~10 frames — stale visibility can never be
|
||||
// served indefinitely.
|
||||
const _DOM_VIS_CHECK_FRAMES = 10;
|
||||
let _domVisCached = false;
|
||||
let _domVisSampledFrame = NaN;
|
||||
let animFrame = null;
|
||||
// Paused-render throttle (feedBack#654). The rAF loop runs
|
||||
// unconditionally and only gates on visibility + ready, never on
|
||||
@@ -733,103 +751,121 @@ function createHighway() {
|
||||
// on the freshly-mounted canvas.
|
||||
let _currentCanvasContextType = '2d';
|
||||
|
||||
// One persistent bundle object per createHighway() instance —
|
||||
// _makeBundle() mutates its fields in place each call instead of
|
||||
// allocating a fresh ~35-field object per rAF frame (steady GC churn
|
||||
// on weak hardware, ×N under splitscreen). Consequences for
|
||||
// consumers: the bundle OBJECT's identity is stable across frames
|
||||
// and carries no meaning; its field values are only valid for the
|
||||
// duration of the current draw call. Array fields (`notes`,
|
||||
// `chords`, `handShapes`, `chordTemplates`, ...) still swap
|
||||
// reference whenever chart data changes — field-identity caches
|
||||
// (e.g. highway_3d's merge caches) rely on that invariant.
|
||||
const _bundleReused = {};
|
||||
|
||||
function _makeBundle() {
|
||||
// Snapshot of current factory state passed to each renderer call.
|
||||
// Arrays and songInfo are LIVE references, not copies — the bundle
|
||||
// itself is rebuilt each frame but its `notes`, `chords`,
|
||||
// `anchors`, `beats`, etc. point at closure state. Renderers
|
||||
// MUST NOT mutate these; treat them as read-only. We don't
|
||||
// Object.freeze or deep-copy for per-frame allocation cost reasons.
|
||||
return {
|
||||
// Timing
|
||||
currentTime,
|
||||
songInfo,
|
||||
isReady: ready,
|
||||
// True while the chart clock is actively advancing; false when
|
||||
// audio is paused / stalled / mid-seek (setTime has kept getting
|
||||
// the same t for > _CHART_MAX_INTERP_MS). This is the same
|
||||
// predicate getTime() uses to decide raw-vs-interpolated, and the
|
||||
// no-anchor boot state reads as not-playing — matching getTime()
|
||||
// returning raw chartTime there. Renderers that run their own
|
||||
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
|
||||
// back to raw instead of extrapolating forward against a frozen
|
||||
// audio sample. Undefined on downlevel hosts → those renderers
|
||||
// keep their own staleness-based fallback.
|
||||
isPlaying: !Number.isNaN(_chartAnchorPerfNow)
|
||||
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS,
|
||||
// Arrays and songInfo are LIVE references, not copies — the
|
||||
// bundle's `notes`, `chords`, `anchors`, `beats`, etc. point at
|
||||
// closure state. Renderers MUST NOT mutate these; treat them as
|
||||
// read-only. We don't Object.freeze or deep-copy for per-frame
|
||||
// cost reasons.
|
||||
const b = _bundleReused;
|
||||
// Timing
|
||||
b.currentTime = currentTime;
|
||||
b.songInfo = songInfo;
|
||||
b.isReady = ready;
|
||||
// True while the chart clock is actively advancing; false when
|
||||
// audio is paused / stalled / mid-seek (setTime has kept getting
|
||||
// the same t for > _CHART_MAX_INTERP_MS). This is the same
|
||||
// predicate getTime() uses to decide raw-vs-interpolated, and the
|
||||
// no-anchor boot state reads as not-playing — matching getTime()
|
||||
// returning raw chartTime there. Renderers that run their own
|
||||
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
|
||||
// back to raw instead of extrapolating forward against a frozen
|
||||
// audio sample. Undefined on downlevel hosts → those renderers
|
||||
// keep their own staleness-based fallback.
|
||||
b.isPlaying = !Number.isNaN(_chartAnchorPerfNow)
|
||||
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS;
|
||||
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
notes: _filteredNotes !== null ? _filteredNotes : notes,
|
||||
chords: _filteredChords !== null ? _filteredChords : chords,
|
||||
anchors: _filteredAnchors !== null ? _filteredAnchors : anchors,
|
||||
beats,
|
||||
sections,
|
||||
chordTemplates,
|
||||
stringCount,
|
||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||
// instrument’s standard open-string layout). Live reference.
|
||||
tuning: songInfo?.tuning,
|
||||
capo: songInfo?.capo,
|
||||
lyrics,
|
||||
lyricsSource,
|
||||
toneChanges,
|
||||
toneBase,
|
||||
// Drum tab payload (or null when the active arrangement has
|
||||
// no drum_tab). Live reference — renderers MUST treat as
|
||||
// read-only. Plugins should prefer this over decoding the
|
||||
// standard `notes` stream when present; absence is the
|
||||
// signal to fall back to legacy MIDI-encoded drums.
|
||||
drumTab,
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
b.notes = _filteredNotes !== null ? _filteredNotes : notes;
|
||||
b.chords = _filteredChords !== null ? _filteredChords : chords;
|
||||
b.anchors = _filteredAnchors !== null ? _filteredAnchors : anchors;
|
||||
b.beats = beats;
|
||||
b.sections = sections;
|
||||
b.chordTemplates = chordTemplates;
|
||||
b.stringCount = stringCount;
|
||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||
// instrument’s standard open-string layout). Live reference.
|
||||
b.tuning = songInfo?.tuning;
|
||||
b.capo = songInfo?.capo;
|
||||
b.lyrics = lyrics;
|
||||
b.lyricsSource = lyricsSource;
|
||||
b.toneChanges = toneChanges;
|
||||
b.toneBase = toneBase;
|
||||
// Drum tab payload (or null when the active arrangement has
|
||||
// no drum_tab). Live reference — renderers MUST treat as
|
||||
// read-only. Plugins should prefer this over decoding the
|
||||
// standard `notes` stream when present; absence is the
|
||||
// signal to fall back to legacy MIDI-encoded drums.
|
||||
b.drumTab = drumTab;
|
||||
|
||||
// Master-difficulty (feedBack#48)
|
||||
mastery: _mastery,
|
||||
hasPhraseData: !!(_phrases && _phrases.length > 0),
|
||||
// When phrase data authored ANY handshape, respect the filtered
|
||||
// list strictly (even when this difficulty leaves it empty) —
|
||||
// otherwise low-mastery levels would surface arp hints that
|
||||
// don't belong. Only fall back to the flat list when the
|
||||
// phrase data carries no handshapes at all (common on DLC
|
||||
// where handshapes ship on the arrangement root).
|
||||
handShapes: (_filteredHandShapes !== null && _phrasesHaveHandShapes)
|
||||
? _filteredHandShapes
|
||||
: handShapes,
|
||||
// Master-difficulty (feedBack#48)
|
||||
b.mastery = _mastery;
|
||||
b.hasPhraseData = !!(_phrases && _phrases.length > 0);
|
||||
// When phrase data authored ANY handshape, respect the filtered
|
||||
// list strictly (even when this difficulty leaves it empty) —
|
||||
// otherwise low-mastery levels would surface arp hints that
|
||||
// don't belong. Only fall back to the flat list when the
|
||||
// phrase data carries no handshapes at all (common on DLC
|
||||
// where handshapes ship on the arrangement root).
|
||||
b.handShapes = (_filteredHandShapes !== null && _phrasesHaveHandShapes)
|
||||
? _filteredHandShapes
|
||||
: handShapes;
|
||||
|
||||
// Display flags
|
||||
inverted: _inverted,
|
||||
lefty: _lefty,
|
||||
renderScale: _effectiveRenderScale(),
|
||||
lyricsVisible: showLyrics,
|
||||
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
||||
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
|
||||
// finger-hint pref rides alongside (default on, independently hideable).
|
||||
teachingMarksVisible: _showTeachingMarks,
|
||||
fingerHintsVisible: _showFingerHints,
|
||||
// Display flags
|
||||
b.inverted = _inverted;
|
||||
b.lefty = _lefty;
|
||||
b.renderScale = _effectiveRenderScale();
|
||||
b.lyricsVisible = showLyrics;
|
||||
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
|
||||
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
|
||||
// finger-hint pref rides alongside (default on, independently hideable).
|
||||
b.teachingMarksVisible = _showTeachingMarks;
|
||||
b.fingerHintsVisible = _showFingerHints;
|
||||
|
||||
// 2D-style helpers (renderers that don't need these can ignore).
|
||||
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
||||
// the factory-level version writes to the default renderer's
|
||||
// closure ctx, which is null for custom renderers. Renderers
|
||||
// that need lefty-aware text should check `bundle.lefty` and
|
||||
// apply the mirror transform themselves on their own context.
|
||||
project,
|
||||
fretX,
|
||||
// 2D-style helpers (renderers that don't need these can ignore).
|
||||
// `fillTextUnmirrored` is deliberately NOT exposed here —
|
||||
// the factory-level version writes to the default renderer's
|
||||
// closure ctx, which is null for custom renderers. Renderers
|
||||
// that need lefty-aware text should check `bundle.lefty` and
|
||||
// apply the mirror transform themselves on their own context.
|
||||
b.project = project;
|
||||
b.fretX = fretX;
|
||||
// Windowed-iteration helpers (stable references): lower-bound
|
||||
// binary searches so custom viz don't full-scan chart arrays per
|
||||
// frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime
|
||||
// keys on `.time` (beats / anchors / sections).
|
||||
b.lowerBoundT = bsearch;
|
||||
b.lowerBoundTime = bsearchTime;
|
||||
|
||||
// Per-note judgment overlay (feedBack#254). Renderers call
|
||||
// this per visible note / chord-note to find out whether a
|
||||
// scorer (note_detect) has flagged it hit / actively-held /
|
||||
// missed, so the gem itself can light up instead of relying
|
||||
// on an overlay ring. Returns null when no provider is set
|
||||
// or it reports nothing for this note; otherwise
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
getNoteState: _noteState, // stable reference — no per-frame allocation
|
||||
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
||||
// attached" apart from "no provider, getNoteState always
|
||||
// returns null" — `getNoteState` always exists on the bundle
|
||||
// so its presence alone isn't a useful "detect mode" signal.
|
||||
// Renderers gate verdict-window cull / draw extensions on this.
|
||||
getNoteStateProvider: _getNoteStateProvider, // stable — see above
|
||||
};
|
||||
// Per-note judgment overlay (feedBack#254). Renderers call
|
||||
// this per visible note / chord-note to find out whether a
|
||||
// scorer (note_detect) has flagged it hit / actively-held /
|
||||
// missed, so the gem itself can light up instead of relying
|
||||
// on an overlay ring. Returns null when no provider is set
|
||||
// or it reports nothing for this note; otherwise
|
||||
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
|
||||
b.getNoteState = _noteState; // stable reference
|
||||
// Lets custom renderers (e.g. highway_3d) tell "is a provider
|
||||
// attached" apart from "no provider, getNoteState always
|
||||
// returns null" — `getNoteState` always exists on the bundle
|
||||
// so its presence alone isn't a useful "detect mode" signal.
|
||||
// Renderers gate verdict-window cull / draw extensions on this.
|
||||
b.getNoteStateProvider = _getNoteStateProvider; // stable — see above
|
||||
return b;
|
||||
}
|
||||
|
||||
const _defaultRenderer = {
|
||||
@@ -1047,6 +1083,7 @@ function createHighway() {
|
||||
// suppress the first transition. Reset to null so the next
|
||||
// rAF tick re-emits unconditionally.
|
||||
_lastVisible = null;
|
||||
_domVisSampledFrame = NaN; // fresh canvas → fresh DOM sample
|
||||
// Defensive notify for plugins / overlays that cache the
|
||||
// canvas element across events. Lazy lookups via
|
||||
// getElementById('highway') do not need this — they'll pick
|
||||
@@ -1246,7 +1283,14 @@ function createHighway() {
|
||||
// hosts that need those use setVisible() instead.
|
||||
function _isHighwayVisible() {
|
||||
if (_visibleOverride !== null) return _visibleOverride;
|
||||
return !!(canvas && canvas.offsetParent !== null);
|
||||
// Throttled offsetParent read — see _DOM_VIS_CHECK_FRAMES above.
|
||||
if (Number.isNaN(_domVisSampledFrame)
|
||||
|| ((_frameIdx - _domVisSampledFrame) | 0) >= _DOM_VIS_CHECK_FRAMES
|
||||
|| ((_frameIdx - _domVisSampledFrame) | 0) < 0) {
|
||||
_domVisCached = !!(canvas && canvas.offsetParent !== null);
|
||||
_domVisSampledFrame = _frameIdx;
|
||||
}
|
||||
return _domVisCached;
|
||||
}
|
||||
|
||||
// Emit only on transition so renderer-side listeners aren't woken
|
||||
@@ -1513,7 +1557,13 @@ function createHighway() {
|
||||
}
|
||||
|
||||
function drawBeats(W, H) {
|
||||
for (const beat of beats) {
|
||||
// Window the beat scan — a long song carries thousands of beats
|
||||
// and iterating (and projecting) all of them per frame was pure
|
||||
// waste; project() culling stays as the safety net.
|
||||
const lo = bsearchTime(beats, currentTime - 0.25);
|
||||
const hi = bsearchTime(beats, currentTime + VISIBLE_SECONDS + 0.25);
|
||||
for (let i = lo; i < hi; i++) {
|
||||
const beat = beats[i];
|
||||
const tOff = beat.time - currentTime;
|
||||
const p = project(tOff);
|
||||
if (!p || p.scale < 0.06) continue;
|
||||
@@ -2624,6 +2674,19 @@ function createHighway() {
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
// Lower-bound binary search for `.time`-keyed arrays (beats, anchors,
|
||||
// sections) — bsearch/bsearchChords key on `.t` and would compare
|
||||
// against undefined here. Exposed to custom viz as
|
||||
// bundle.lowerBoundTime.
|
||||
function bsearchTime(arr, time) {
|
||||
let lo = 0, hi = arr.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid].time < time) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
|
||||
//
|
||||
@@ -2956,6 +3019,7 @@ function createHighway() {
|
||||
init(canvasEl, container) {
|
||||
canvas = canvasEl;
|
||||
_resizeContainer = container || null;
|
||||
_domVisSampledFrame = NaN; // new mount → fresh DOM sample
|
||||
// Size the canvas BEFORE installing the renderer so
|
||||
// _setRenderer's init/resize calls see the real dimensions
|
||||
// instead of the default 300x150 backing store. Otherwise
|
||||
@@ -2997,6 +3061,9 @@ function createHighway() {
|
||||
|
||||
resize() {
|
||||
if (!canvas) return;
|
||||
// Layout just changed (window resize / container swap) —
|
||||
// re-sample DOM visibility on the next check.
|
||||
_domVisSampledFrame = NaN;
|
||||
let w, h;
|
||||
if (_resizeContainer) {
|
||||
const rect = _resizeContainer.getBoundingClientRect();
|
||||
@@ -3771,6 +3838,10 @@ function createHighway() {
|
||||
// rAF tick.
|
||||
setVisible(v) {
|
||||
_visibleOverride = (v === null || v === undefined) ? null : !!v;
|
||||
// Clearing the override resumes DOM-based detection — force a
|
||||
// fresh offsetParent sample so the resulting transition (if
|
||||
// any) emits now, not after the throttle window.
|
||||
if (_visibleOverride === null) _domVisSampledFrame = NaN;
|
||||
_emitVisibilityIfChanged();
|
||||
},
|
||||
// Snapshot of the current visibility state (the override if
|
||||
@@ -3779,6 +3850,12 @@ function createHighway() {
|
||||
// can call this once to sync their initial state — the event
|
||||
// is transition-only and won't re-fire for late subscribers.
|
||||
isVisible() {
|
||||
// Force a fresh DOM sample — this is a documented "live DOM
|
||||
// check" for late subscribers seeding initial state, so it
|
||||
// must not serve the rAF loop's throttled cache (up to
|
||||
// ~166 ms stale). Called rarely; the layout-read cost that
|
||||
// motivated the throttle only matters per-frame.
|
||||
_domVisSampledFrame = NaN;
|
||||
return _isHighwayVisible();
|
||||
},
|
||||
getNotes() { return notes; },
|
||||
|
||||
+1
-1
@@ -72,7 +72,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) ══════════════════════════════════════════ -->
|
||||
<div id="home" class="screen active">
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -71,7 +71,7 @@
|
||||
'<div class="flex items-center gap-2 text-xs text-fb-textDim mb-3">' +
|
||||
'<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13M9 13l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' +
|
||||
'<span>Audio Routing</span></div>' +
|
||||
'<div class="flex items-center gap-2 text-[11px] text-fb-textDim">' +
|
||||
'<div class="flex items-center gap-2 text-[0.6875rem] text-fb-textDim">' +
|
||||
dot(st.inputAvailable) + '<span>Input</span>' +
|
||||
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
|
||||
dot(st.effectActive) + '<span>VST/NAM/IR</span>' +
|
||||
|
||||
+59
-11
@@ -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
|
||||
@@ -296,7 +330,7 @@
|
||||
'<div class="shrink-0 flex flex-col gap-[3px] items-center justify-center">' + meter + '</div>' +
|
||||
'<div class="min-w-0 text-white text-center leading-none">' +
|
||||
'<div data-tuner-note class="text-2xl font-black italic tracking-tighter leading-none">' + esc(initNote) + '</div>' +
|
||||
'<div data-tuner-hz class="text-[9px] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
|
||||
'<div data-tuner-hz class="text-[0.5625rem] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
|
||||
'</div></button>' +
|
||||
'</div>';
|
||||
host.querySelector('[data-open-tuner]').addEventListener('click', (e) => {
|
||||
@@ -399,7 +433,7 @@
|
||||
// Live working-tuning label: dim while you're still in your home tuning,
|
||||
// amber once you've retuned. Omitted if the host doesn't expose
|
||||
// workingTuning (feature-detect → the card looks exactly as before).
|
||||
(wt ? '<span class="text-[9px] leading-none font-semibold max-w-full truncate px-0.5 ' +
|
||||
(wt ? '<span class="text-[0.5625rem] leading-none font-semibold max-w-full truncate px-0.5 ' +
|
||||
(wt.isHome ? 'text-fb-textDim' : 'text-amber-400') + '">' + esc(wt.short) + '</span>' : '') +
|
||||
'<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>' +
|
||||
'</button>' +
|
||||
@@ -410,21 +444,24 @@
|
||||
? '<div class="flex items-center justify-between gap-2 pb-1 border-b border-fb-border/40">' +
|
||||
'<div class="min-w-0"><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim">Now in</div>' +
|
||||
'<div class="text-xs font-semibold text-amber-400 truncate flex items-center gap-1">' + esc(wt.label) + ' ' + provenanceGlyph(wt.provenance) + '</div></div>' +
|
||||
'<button type="button" data-inst-reset title="Reset this instrument to its home tuning" class="shrink-0 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
|
||||
'<button type="button" data-inst-reset title="Reset this instrument to its home tuning" class="shrink-0 text-[0.6875rem] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
instRow('Instrument', ['guitar', 'bass'].map((v) =>
|
||||
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('')) +
|
||||
'<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<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
|
||||
// disabled, selected 'Custom' entry so the dropdown reflects reality
|
||||
// (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="flex justify-between text-[10px] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></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 +491,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 +500,21 @@
|
||||
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.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) }));
|
||||
@@ -478,7 +526,7 @@
|
||||
});
|
||||
}
|
||||
function instRow(label, inner) {
|
||||
return '<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
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>';
|
||||
}
|
||||
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 ' +
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -23,11 +23,18 @@
|
||||
reg.register({
|
||||
id: 'core.edit-metadata',
|
||||
pluginId: 'core',
|
||||
label: 'Edit metadata',
|
||||
label: 'Details',
|
||||
placement: 'menu',
|
||||
order: 10,
|
||||
applies: (song) => !!(song && song.filename),
|
||||
run: (song) => {
|
||||
// Prefer the v3 Details drawer (identity + personal difficulty / tags
|
||||
// / notes); fall back to the legacy edit-metadata modal where the v3
|
||||
// songs screen hasn't defined the opener (e.g. an older shell).
|
||||
if (typeof window.__fbOpenSongDetails === 'function') {
|
||||
window.__fbOpenSongDetails(song);
|
||||
return;
|
||||
}
|
||||
if (typeof window.openEditModal !== 'function') return;
|
||||
window.openEditModal({
|
||||
f: song.filename, t: song.title || '', a: song.artist || '',
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
const l = fmtName(song);
|
||||
if (!l) return '';
|
||||
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
|
||||
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||
}
|
||||
// Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid
|
||||
// and a corner badge would collide — sits next to the card's label instead.
|
||||
@@ -75,7 +75,7 @@
|
||||
const l = fmtName(song);
|
||||
if (!l) return '';
|
||||
const c = l === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
|
||||
return '<span class="' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
|
||||
return '<span class="' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
|
||||
}
|
||||
|
||||
// ── Continue-Playing resume ──────────────────────────────────────────---
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
|
||||
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
|
||||
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
|
||||
//
|
||||
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
|
||||
// centred panel, light focus trap, Esc closes, overlay click closes) but
|
||||
// layers at z-[200] — the songs.js centered-modal tier — because one of its
|
||||
// openers is the details drawer (z-[61]), which sits above match-review's
|
||||
// z-40/50 pair.
|
||||
//
|
||||
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
|
||||
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
|
||||
// the EXISTING …/art/url route (the override lane: never evicted, survives
|
||||
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
|
||||
// existing …/art/upload (GIF stays upload-only + local-only; the server's
|
||||
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
|
||||
// like the match layer): the modal just closes and the art refreshes.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
// Provenance badge text — same vocabulary as the match layer.
|
||||
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
|
||||
|
||||
let _cur = null; // {filename, title} while the picker is open
|
||||
let _abort = null; // in-flight candidates fetch — cancelled on close
|
||||
let _busy = false; // an apply is running — ignore further tile clicks
|
||||
let _lastFocus = null;
|
||||
|
||||
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
|
||||
|
||||
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
|
||||
// every rendered <img> pointing at this song's art with a fresh v so the
|
||||
// new pick paints everywhere it's currently shown (grid card, drawer
|
||||
// preview, list row) without a full reload.
|
||||
function refreshArt(fn) {
|
||||
const base = artBase(fn);
|
||||
document.querySelectorAll('img').forEach((img) => {
|
||||
const src = img.getAttribute('src') || '';
|
||||
if (src.split('?')[0] === base) {
|
||||
img.src = base + '?v=' + Date.now();
|
||||
img.style.visibility = 'visible';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-imgpick-modal');
|
||||
if (m) return m;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'v3-imgpick-overlay';
|
||||
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
|
||||
overlay.addEventListener('click', close);
|
||||
document.body.appendChild(overlay);
|
||||
m = document.createElement('div');
|
||||
m.id = 'v3-imgpick-modal';
|
||||
// Appended after the overlay: same z tier, DOM order paints it above.
|
||||
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
|
||||
m.innerHTML = '<div id="v3-imgpick-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Change cover"></div>';
|
||||
m.addEventListener('keydown', onKeydown);
|
||||
document.body.appendChild(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
// Light focus trap: cycle within the panel (mirrors match-review).
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
if (!panel) return;
|
||||
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
|
||||
// onerror .hidden, unloadable candidates, .hidden buttons) must never
|
||||
// catch a Tab. offsetParent is null for display:none / .hidden.
|
||||
const foci = Array.from(
|
||||
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
|
||||
).filter((el) => el.offsetParent !== null);
|
||||
if (!foci.length) return;
|
||||
const first = foci[0], last = foci[foci.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
|
||||
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
|
||||
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
|
||||
_cur = null;
|
||||
_busy = false;
|
||||
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
|
||||
_lastFocus = null;
|
||||
}
|
||||
|
||||
// One tile: a 6rem square art/icon face + a caption underneath.
|
||||
function tileHtml(attrs, face, label, hidden) {
|
||||
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
|
||||
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
|
||||
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
|
||||
}
|
||||
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
|
||||
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
|
||||
|
||||
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
|
||||
|
||||
function render(panel) {
|
||||
const fn = _cur.filename;
|
||||
// Fresh ?v so a reopened picker never shows a stale "current".
|
||||
const curSrc = artBase(fn) + '?v=' + Date.now();
|
||||
panel.innerHTML =
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||||
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
|
||||
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||||
|
||||
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
|
||||
// Left: the current cover + its provenance.
|
||||
'<div class="shrink-0">' +
|
||||
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
|
||||
'<div class="pt-1 flex items-center gap-1.5">' +
|
||||
'<span class="text-xs text-fb-textDim">Current</span>' +
|
||||
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
|
||||
'</div></div>' +
|
||||
// Right: the candidate tiles. First row acts instantly; CAA
|
||||
// candidates land behind the one /art/candidates fetch.
|
||||
'<div class="min-w-0 flex-1 space-y-3">' +
|
||||
'<div class="flex flex-wrap gap-3">' +
|
||||
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
|
||||
// Pack tile renders instantly and self-hides when the song ships
|
||||
// no art of its own (?source=pack 404s → img onerror); the
|
||||
// candidates response reconciles it either way.
|
||||
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
|
||||
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
|
||||
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
|
||||
'</div>' +
|
||||
'<div data-ip-caa>' +
|
||||
'<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>' +
|
||||
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
|
||||
'</div></div>' +
|
||||
'<input type="file" accept="image/*" data-ip-file class="hidden">';
|
||||
|
||||
wire(panel);
|
||||
}
|
||||
|
||||
function wire(panel) {
|
||||
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
|
||||
// The pack tile self-hides when there is no pack art to show.
|
||||
const packTile = panel.querySelector('[data-ip-act="pack"]');
|
||||
const packImg = packTile ? packTile.querySelector('img') : null;
|
||||
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
|
||||
|
||||
const file = panel.querySelector('[data-ip-file]');
|
||||
file?.addEventListener('change', () => {
|
||||
const f = file.files && file.files[0];
|
||||
if (!f) return;
|
||||
const rd = new FileReader();
|
||||
rd.onload = (e) => apply('upload', e.target.result);
|
||||
rd.readAsDataURL(f);
|
||||
});
|
||||
|
||||
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (_busy) return;
|
||||
const act = btn.getAttribute('data-ip-act');
|
||||
if (act === 'keep') { close(); return; }
|
||||
if (act === 'pack') { apply('pack'); return; }
|
||||
if (act === 'upload') { file?.click(); return; }
|
||||
if (act === 'url') {
|
||||
// window.prompt is a silent no-op in Electron — use the
|
||||
// project's injection-safe async modal; fall back to prompt
|
||||
// only if it isn't loaded (mirrors other v3 callers' guard).
|
||||
const ask = (typeof window.uiPrompt === 'function')
|
||||
? window.uiPrompt({
|
||||
title: 'Paste URL',
|
||||
label: 'Paste an image link (http or https)',
|
||||
okLabel: 'Set cover',
|
||||
placeholder: 'https://…',
|
||||
})
|
||||
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
|
||||
const u = String((await ask) || '').trim();
|
||||
if (u) apply('url', u);
|
||||
}
|
||||
});
|
||||
});
|
||||
panel.querySelector('[data-ip-close]')?.focus();
|
||||
}
|
||||
|
||||
// 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.
|
||||
function loadCandidates(panel) {
|
||||
const fn = _cur.filename;
|
||||
// Reopening without an intervening close() can leave a prior fetch in
|
||||
// flight — cancel it so only the newest request settles the tiles.
|
||||
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
|
||||
_abort = new AbortController();
|
||||
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
|
||||
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
|
||||
}
|
||||
|
||||
function patchCandidates(panel, body) {
|
||||
const wrap = panel.querySelector('[data-ip-caa]');
|
||||
if (!wrap) return;
|
||||
const list = (body && body.candidates) || [];
|
||||
// Reconcile the instant tiles with what the server actually knows.
|
||||
const cur = list.find((c) => c.kind === 'current');
|
||||
const badge = panel.querySelector('[data-ip-prov]');
|
||||
if (badge && cur && PROV_LABEL[cur.provenance]) {
|
||||
badge.textContent = PROV_LABEL[cur.provenance];
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
const packTile = panel.querySelector('[data-ip-act="pack"]');
|
||||
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
|
||||
|
||||
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
|
||||
if (!caa.length) { wrap.innerHTML = ''; return; }
|
||||
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
|
||||
'<div class="flex flex-wrap gap-3">' +
|
||||
caa.map((c, i) => tileHtml(
|
||||
'data-ip-cand="' + i + '"',
|
||||
imgFace(c.thumb_url),
|
||||
c.label || 'Cover')).join('') +
|
||||
'</div>';
|
||||
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
|
||||
// A candidate whose thumb can't load isn't offerable — hide it
|
||||
// rather than let a click apply an image nobody saw.
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden');
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply a pick through the EXISTING routes; silent on success (close +
|
||||
// cache-busted refresh), inline note on failure (the modal stays open so
|
||||
// another tile can be tried).
|
||||
async function apply(kind, arg) {
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!fn || _busy) return;
|
||||
_busy = true;
|
||||
let ok = false;
|
||||
try {
|
||||
let r = null;
|
||||
if (kind === 'url') {
|
||||
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: arg }),
|
||||
});
|
||||
} else if (kind === 'upload') {
|
||||
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: arg }),
|
||||
});
|
||||
} else if (kind === 'pack') {
|
||||
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
|
||||
}
|
||||
if (r && r.ok) {
|
||||
// The art routes report soft failures as {error} bodies.
|
||||
const body = await r.json().catch(() => ({}));
|
||||
ok = !body.error;
|
||||
}
|
||||
} catch (_) { ok = false; }
|
||||
_busy = false;
|
||||
if (ok) { close(); refreshArt(fn); return; }
|
||||
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
|
||||
if (status) {
|
||||
status.textContent = 'Couldn’t set that cover — try another image.';
|
||||
status.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function openImagePicker(opts) {
|
||||
const filename = opts && opts.filename;
|
||||
if (!filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_cur = { filename: filename, title: (opts && opts.title) || filename };
|
||||
_busy = false;
|
||||
const m = ensureModal();
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
render(panel);
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
|
||||
loadCandidates(panel);
|
||||
}
|
||||
|
||||
window.__fbOpenImagePicker = openImagePicker;
|
||||
})();
|
||||
+150
-1
@@ -60,6 +60,24 @@
|
||||
} catch (_) { /* file:// or sandboxed iframe */ }
|
||||
})();
|
||||
</script>
|
||||
<!-- Interface size (Accessibility): apply the saved UI scale to the root
|
||||
font-size BEFORE any stylesheet paints, so there is no flash-of-reflow
|
||||
on load. Mirrors capabilities/interface-scale.js, which is the
|
||||
authoritative owner and re-applies once it parses. Relative % (never a
|
||||
px literal) so a raised browser/OS base font size is respected. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('v3-interface-scale');
|
||||
if (raw == null) return;
|
||||
var v = Math.min(1.5, Math.max(0.85, parseFloat(raw)));
|
||||
if (!isFinite(v) || Math.abs(v - 1) < 0.001) return;
|
||||
var el = document.documentElement;
|
||||
el.style.setProperty('--fb-scale', String(v));
|
||||
el.style.fontSize = (v * 100).toFixed(2) + '%';
|
||||
} catch (_) { /* file:// or private mode */ }
|
||||
})();
|
||||
</script>
|
||||
<!-- fee[dB]ack mark (the [dB] motif). SVG primary, PNG fallback. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/v3/brand/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/v3/brand/favicon-32.png">
|
||||
@@ -96,6 +114,7 @@
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
<script src="/static/capabilities/interface-scale.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -103,7 +122,7 @@
|
||||
the inline brand is the no-JS fallback. -->
|
||||
<aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex">
|
||||
<div id="v3-brand" class="p-6">
|
||||
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
|
||||
<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
|
||||
</div>
|
||||
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
|
||||
</aside>
|
||||
@@ -359,6 +378,7 @@
|
||||
panels (manifest settings.category routes the others). -->
|
||||
<div class="fb-tabbar" id="settings-tabbar">
|
||||
<button type="button" class="fb-tab" data-tab="gameplay">Gameplay</button>
|
||||
<button type="button" class="fb-tab" data-tab="accessibility">Accessibility</button>
|
||||
<button type="button" class="fb-tab" data-tab="audio">Audio</button>
|
||||
<button type="button" class="fb-tab" data-tab="graphics">Graphics</button>
|
||||
<button type="button" class="fb-tab" data-tab="keybinds">Keybinds</button>
|
||||
@@ -409,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>
|
||||
@@ -540,6 +577,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ ACCESSIBILITY ═══════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="accessibility">
|
||||
<div class="fb-tabpanel-head"><h3>Accessibility</h3></div>
|
||||
<div class="fb-srows">
|
||||
<!-- Interface size -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<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="M4 7V5a1 1 0 011-1h14a1 1 0 011 1v2M9 20h6M12 4v16"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Interface size</div>
|
||||
<div class="fb-srow-desc">Make the app’s menus, buttons and text larger or smaller. This changes the <strong>app interface</strong> — not the notes on the note highway (set those under <strong>Graphics</strong>). On desktop you can also press <strong>Ctrl +</strong> / <strong>Ctrl −</strong> to zoom the whole window.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-seg" id="setting-interface-size" role="group" aria-label="Interface size">
|
||||
<button type="button" class="fb-seg-btn" data-scale="0.90" aria-pressed="false" onclick="window.feedBack.scale.set(0.90)">Small</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.00" aria-pressed="false" onclick="window.feedBack.scale.set(1.00)">Medium</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.15" aria-pressed="false" onclick="window.feedBack.scale.set(1.15)">Large</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.30" aria-pressed="false" onclick="window.feedBack.scale.set(1.30)">Extra Large</button>
|
||||
</div>
|
||||
<p class="fb-srow-desc" style="margin-top:.55rem;">Larger sizes are recommended for large or high-resolution displays.</p>
|
||||
<details class="fb-finetune">
|
||||
<summary>Fine-tune size</summary>
|
||||
<div class="fb-finetune-body">
|
||||
<input type="range" id="setting-interface-size-slider" min="85" max="150" step="5" value="100"
|
||||
oninput="window.feedBack.scale.set(this.value / 100, { persist: false })"
|
||||
onchange="window.feedBack.scale.set(this.value / 100)" class="fb-srow-wide slider-input"
|
||||
aria-label="Interface size percent">
|
||||
<span class="fb-seg-val"><span id="setting-interface-size-val">100</span>%</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ AUDIO ═══════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="audio">
|
||||
<div class="fb-tabpanel-head"><h3>Audio Settings</h3></div>
|
||||
@@ -681,6 +752,76 @@
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata matching (P8 — wired by static/v3/match-review.js) -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Metadata matching</div>
|
||||
<div class="fb-srow-desc">Matches your charts against MusicBrainz in the background to tidy names, years and genres — display only, your files are never modified. Matches at or above the confidence level apply automatically; the rest wait in the library's review queue. Turning this off never disables manual match fixes.</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"><input type="checkbox" id="enrich-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Match songs automatically</label>
|
||||
<label class="flex items-center gap-2">Auto-apply confidence
|
||||
<select id="enrich-threshold" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="0.85">85% — more auto-matches</option>
|
||||
<option value="0.9" selected>90% (recommended)</option>
|
||||
<option value="0.95">95% — cautious</option>
|
||||
<option value="1.01">Always review</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- R1 scraper options: sources (who may be contacted) ×
|
||||
auto-apply fields (what a confident match fills in). -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Sources</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-musicbrainz" checked class="rounded border-gray-600 bg-dark-700 text-accent"> MusicBrainz — names, years, genres</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-caa" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover Art Archive — album covers</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Auto-apply</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-names" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Names</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-year" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Year</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-genres" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Genres</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-art" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover art</label>
|
||||
</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">
|
||||
<option value="missing_first" selected>Missing info first</option>
|
||||
<option value="artist">Artist A–Z</option>
|
||||
<option value="recent">Recently added</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button id="enrich-match-now" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Match Now</button>
|
||||
<span id="enrich-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Artist pages (PR-B — wired by static/v3/match-review.js). Sits
|
||||
beside the Metadata matching card; the Settings→Library tab
|
||||
regroup is a separate PR. -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Artist pages</div>
|
||||
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</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"><input type="checkbox" id="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
@@ -1118,14 +1259,22 @@
|
||||
<script src="/static/v3/pedal-cables.js"></script>
|
||||
<script src="/static/v3/plugins-page.js"></script>
|
||||
<script src="/static/v3/card-actions-core.js"></script>
|
||||
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
|
||||
on build, so the module must already be registered. -->
|
||||
<script src="/static/v3/match-review.js"></script>
|
||||
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
|
||||
the cover picker (window.__fbOpenImagePicker). -->
|
||||
<script src="/static/v3/image-picker.js"></script>
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<script src="/static/v3/settings.js"></script>
|
||||
<script src="/static/v3/interface-size-ui.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
<script src="/static/v3/onboarding-tour.js"></script>
|
||||
<script src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script src="/static/v3/feedbarcade.js"></script>
|
||||
<script src="/static/v3/player-chrome.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// v3 first-run "Interface size" nudge.
|
||||
//
|
||||
// The Accessibility → Interface size control is the primary discovery path, but
|
||||
// the exact person who needs it — someone on a large, low-DPI display (e.g. a
|
||||
// 32" 1440p panel with no OS scaling) — is the one least likely to go hunting
|
||||
// for it. So, ONCE, for that specific display profile, surface a gentle,
|
||||
// dismissible toast that deep-links to the control. Never fires if the user has
|
||||
// already touched the setting, on smaller/high-DPI displays, or more than once.
|
||||
//
|
||||
// Plain non-module script; degrades to a no-op without the bus, fbNotify, or DOM.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SEEN_KEY = 'v3-interface-size-nudged';
|
||||
var SCALE_KEY = 'v3-interface-scale';
|
||||
|
||||
function alreadyHandled() {
|
||||
try {
|
||||
return localStorage.getItem(SEEN_KEY) === '1' || localStorage.getItem(SCALE_KEY) != null;
|
||||
} catch (_) { return true; }
|
||||
}
|
||||
|
||||
// The target profile: a physically large viewport rendered near 1:1 (so the
|
||||
// OS isn't already enlarging things). This is the eye-strain case.
|
||||
function isLargeLowDpiDisplay() {
|
||||
var w = window.innerWidth || 0;
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
return w >= 1800 && dpr <= 1.25;
|
||||
}
|
||||
|
||||
// Don't interrupt a first-run modal (e.g. profile onboarding). If one is up,
|
||||
// leave the flag UNSET so the nudge gets another chance on a later launch.
|
||||
function aModalIsOpen() {
|
||||
var dialogs = document.querySelectorAll('[role="dialog"], .fixed.inset-0');
|
||||
for (var i = 0; i < dialogs.length; i++) {
|
||||
var el = dialogs[i];
|
||||
if (el.offsetParent !== null && el.getClientRects().length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function openSetting() {
|
||||
try {
|
||||
if (typeof window.showScreen === 'function') window.showScreen('settings');
|
||||
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
|
||||
if (b.dataset.tab === 'accessibility') b.click();
|
||||
});
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
function maybeNudge() {
|
||||
if (alreadyHandled()) return;
|
||||
if (!isLargeLowDpiDisplay()) return;
|
||||
if (!window.fbNotify || typeof window.fbNotify.show !== 'function') return;
|
||||
if (aModalIsOpen()) return; // try again next launch
|
||||
|
||||
try { localStorage.setItem(SEEN_KEY, '1'); } catch (_) { /* private mode */ }
|
||||
|
||||
var card = window.fbNotify.show({
|
||||
title: 'Text looking small?',
|
||||
message: 'Make the menus and text larger — tap to open Interface size.',
|
||||
icon: '🔍',
|
||||
accent: '#0ea5e9',
|
||||
durationMs: 9000,
|
||||
});
|
||||
if (card && card.addEventListener) card.addEventListener('click', openSetting);
|
||||
}
|
||||
|
||||
function start() {
|
||||
// Let the app settle (boot, any onboarding) before offering the nudge.
|
||||
setTimeout(maybeNudge, 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
// v3 Settings → Accessibility: keeps the "Interface size" control in sync with
|
||||
// the host `feedBack.scale` capability. The buttons/slider WRITE via inline
|
||||
// `feedBack.scale.set(...)`; this module only REFLECTS current state (active
|
||||
// preset, slider position, % readout) so the control mirrors the live value on
|
||||
// load, on every change, and each time Settings is opened.
|
||||
//
|
||||
// Plain non-module script, matching the rest of static/v3/*. Null-guarded so it
|
||||
// no-ops on the classic v2 page (which has no Accessibility panel).
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function sync(state) {
|
||||
if (!state) {
|
||||
var cap = window.feedBack && window.feedBack.scale;
|
||||
state = cap && typeof cap.get === 'function' ? cap.get() : null;
|
||||
}
|
||||
if (!state) return;
|
||||
|
||||
var seg = document.getElementById('setting-interface-size');
|
||||
if (seg) {
|
||||
seg.querySelectorAll('.fb-seg-btn').forEach(function (b) {
|
||||
var on = Math.abs(parseFloat(b.dataset.scale) - state.value) < 0.001;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
var slider = document.getElementById('setting-interface-size-slider');
|
||||
if (slider && document.activeElement !== slider) {
|
||||
slider.value = String(Math.round(state.value * 100));
|
||||
}
|
||||
var val = document.getElementById('setting-interface-size-val');
|
||||
if (val) val.textContent = String(Math.round(state.value * 100));
|
||||
}
|
||||
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
// Fires on every set() and once on load. The bus delivers a CustomEvent,
|
||||
// so we ignore the arg and read the authoritative value via sync() → get().
|
||||
window.feedBack.on('scale:changed', function () { sync(); });
|
||||
// Re-sync when the user opens Settings (the panel may have re-rendered).
|
||||
window.feedBack.on('screen:changed', function (e) {
|
||||
var id = e && (e.detail ? e.detail.id : e.id);
|
||||
if (id === 'settings') sync();
|
||||
});
|
||||
}
|
||||
// Settings markup is static, but re-sync when settings.js signals it wired.
|
||||
document.addEventListener('v3:settings-rendered', function () { sync(); });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
|
||||
} else {
|
||||
sync();
|
||||
}
|
||||
|
||||
window.feedBackInterfaceSize = { sync: sync };
|
||||
})();
|
||||
@@ -56,8 +56,8 @@
|
||||
const shown = arr.slice(0, max || 6);
|
||||
const extra = arr.length - shown.length;
|
||||
let html = shown.map((t) =>
|
||||
'<span class="text-[10px] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
|
||||
if (extra > 0) html += '<span class="text-[10px] text-fb-textDim">+' + extra + '</span>';
|
||||
'<span class="text-[0.625rem] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
|
||||
if (extra > 0) html += '<span class="text-[0.625rem] text-fb-textDim">+' + extra + '</span>';
|
||||
return '<div class="flex flex-wrap gap-1">' + html + '</div>';
|
||||
}
|
||||
function progressBar(passed, total) {
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// Match-Review UI (P8 — library-metadata design §5/§11). A self-contained
|
||||
// module: the ambient "⚑ N to review" chip lives in the songs toolbar
|
||||
// (songs.js renders the element and calls the hooks below); the review MODAL,
|
||||
// the per-field available/missing detail, and the Settings → Library
|
||||
// "Metadata matching" card behaviour all live here.
|
||||
//
|
||||
// The modal reviews ONE chart at a time (the scraper-review model from
|
||||
// media-server / emulation-frontend apps): the chart's current metadata —
|
||||
// with explicit "Missing: …" chips — above the candidate list, each
|
||||
// candidate carrying "Adds / Shows as" chips, with Skip / Not a match /
|
||||
// Search instead / Use selected plus ‹ › navigation.
|
||||
//
|
||||
// Engagement guardrails (§11): opt-in tool-state, not a score. The chip only
|
||||
// appears when there is something to review, matching is silent on success
|
||||
// (no toasts, no sounds — hearing-safe), and nothing here ever writes to
|
||||
// pack files; a confirmed match only improves the local display cache.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
function artUrl(song) {
|
||||
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
|
||||
return '/api/song/' + enc(song.filename) + '/art' + v;
|
||||
}
|
||||
|
||||
function fmtDur(sec) {
|
||||
if (!sec && sec !== 0) return '';
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ── Ambient chip + the Settings card's status line ───────────────────────
|
||||
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
|
||||
// calls window.__fbMatchReviewChip() after each toolbar build; review
|
||||
// actions here re-call it. The same fetch feeds the Settings status line
|
||||
// and, while a pass is running, a quiet toolbar progress line (below).
|
||||
// Silent on failure — surfaces just stay as they are.
|
||||
let _chipBusy = false;
|
||||
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
|
||||
|
||||
// Quiet library-visible progress (launch polish): a plain text line next
|
||||
// to the review chip while the background pass is working through the
|
||||
// queue — "Matching your library — X of Y". No toast, no sound; it simply
|
||||
// disappears when the pass finishes (hearing-safe, design §11).
|
||||
function _setProgressLine(running, states, total) {
|
||||
let el = document.getElementById('v3-songs-match-progress');
|
||||
const unscanned = states.unscanned || 0;
|
||||
if (!running || unscanned <= 0 || total <= 0) {
|
||||
if (el) el.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
const chip = document.getElementById('v3-songs-match-review');
|
||||
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
|
||||
el = document.createElement('span');
|
||||
el.id = 'v3-songs-match-progress';
|
||||
el.className = 'text-xs text-fb-textDim';
|
||||
chip.insertAdjacentElement('afterend', el);
|
||||
}
|
||||
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
|
||||
}
|
||||
|
||||
// One-time transparency toast (launch polish): the first time this
|
||||
// install is observed actually matching a real library, say plainly what
|
||||
// is contacted, where results live, and where the switch is. Wrapped like
|
||||
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
|
||||
// never break the chip.
|
||||
function _announceOnce(running, total) {
|
||||
try {
|
||||
if (!running || total <= 0) return;
|
||||
if (localStorage.getItem('fb_enrich_announce_v1')) return;
|
||||
localStorage.setItem('fb_enrich_announce_v1', '1');
|
||||
window.fbNotify?.show({
|
||||
title: 'Library matching is on',
|
||||
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
|
||||
icon: '📚',
|
||||
});
|
||||
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
|
||||
}
|
||||
|
||||
async function refreshChip() {
|
||||
if (_chipBusy) return;
|
||||
_chipBusy = true;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/status');
|
||||
if (!r.ok) return;
|
||||
const body = await r.json();
|
||||
const st = body.states || {};
|
||||
const n = st.review || 0;
|
||||
const chip = document.getElementById('v3-songs-match-review');
|
||||
if (chip) {
|
||||
chip.textContent = '⚑ ' + n + ' to review';
|
||||
chip.classList.toggle('hidden', !n);
|
||||
}
|
||||
const line = document.getElementById('enrich-status');
|
||||
if (line) {
|
||||
const parts = [
|
||||
((st.matched || 0) + (st.manual || 0)) + ' matched',
|
||||
n + ' to review',
|
||||
(st.failed || 0) + ' unmatched',
|
||||
];
|
||||
if (st.unscanned) parts.push(st.unscanned + ' queued');
|
||||
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
|
||||
}
|
||||
const running = !!body.running;
|
||||
const total = body.total_songs || 0;
|
||||
_setProgressLine(running, st, total);
|
||||
_announceOnce(running, total);
|
||||
// Poll only while a pass is actually running; a single guarded
|
||||
// interval, cleared the moment the pass stops (no leaks).
|
||||
if (running && !_pollTimer) {
|
||||
_pollTimer = setInterval(refreshChip, 5000);
|
||||
} else if (!running && _pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline — leave surfaces as they are, but stop any poll so a
|
||||
// dead server isn't pinged every 5s forever (the next toolbar
|
||||
// build / settings open restarts it if a pass is still running).
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
} finally {
|
||||
_chipBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review modal (body-appended singleton, one chart at a time) ─────────
|
||||
let _queue = [];
|
||||
let _idx = 0;
|
||||
let _lastFocus = null;
|
||||
let _single = false; // Fix-match mode: one song, no queue navigation
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-match-modal');
|
||||
if (m) return m;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'v3-match-overlay';
|
||||
overlay.className = 'fixed inset-0 bg-black/60 z-40 hidden';
|
||||
overlay.addEventListener('click', closeModal);
|
||||
document.body.appendChild(overlay);
|
||||
m = document.createElement('div');
|
||||
m.id = 'v3-match-modal';
|
||||
m.className = 'fixed inset-0 z-50 hidden flex items-center justify-center p-4 pointer-events-none';
|
||||
m.innerHTML = '<div id="v3-match-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Match review"></div>';
|
||||
m.addEventListener('keydown', onModalKeydown);
|
||||
document.body.appendChild(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function isTyping(e) {
|
||||
const t = e.target;
|
||||
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA');
|
||||
}
|
||||
|
||||
function onModalKeydown(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); closeModal(); return; }
|
||||
if (e.key === 'ArrowLeft' && !isTyping(e)) { e.preventDefault(); nav(-1); return; }
|
||||
if (e.key === 'ArrowRight' && !isTyping(e)) { e.preventDefault(); nav(1); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
// Light focus trap: cycle within the panel.
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
const foci = panel.querySelectorAll('button, input, [tabindex="0"]');
|
||||
if (!foci.length) return;
|
||||
const first = foci[0], last = foci[foci.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
_lastFocus = document.activeElement;
|
||||
_single = false;
|
||||
const m = ensureModal();
|
||||
renderLoading();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
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.
|
||||
function fixMatch(song) {
|
||||
if (!song || !song.filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_single = true;
|
||||
_queue = [{
|
||||
filename: song.filename, title: song.title || song.filename,
|
||||
artist: song.artist || '', album: song.album || '',
|
||||
year: song.year || '', duration: song.duration,
|
||||
mtime: song.mtime, candidates: [],
|
||||
}];
|
||||
_idx = 0;
|
||||
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();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('v3-match-modal')?.classList.add('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.add('hidden');
|
||||
_single = false;
|
||||
refreshChip();
|
||||
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } }
|
||||
_lastFocus = null;
|
||||
}
|
||||
|
||||
function nav(step) {
|
||||
if (!_queue.length) return;
|
||||
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/review?limit=200');
|
||||
_queue = r.ok ? ((await r.json()).songs || []) : [];
|
||||
} catch (_) { _queue = []; }
|
||||
_idx = 0;
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
function headerHtml() {
|
||||
const counter = (_queue.length && !_single)
|
||||
? '<span class="flex items-center gap-1 text-xs text-fb-textDim">' +
|
||||
'<button data-mr-prev class="px-2 py-1 rounded hover:text-fb-text' + (_idx === 0 ? ' opacity-30' : '') + '" aria-label="Previous">‹</button>' +
|
||||
(_idx + 1) + ' of ' + _queue.length +
|
||||
'<button data-mr-next class="px-2 py-1 rounded hover:text-fb-text' + (_idx >= _queue.length - 1 ? ' opacity-30' : '') + '" aria-label="Next">›</button></span>'
|
||||
: '';
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">' + (_single ? 'Fix match' : 'Match review') + '</h3>' + counter +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>';
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-2"><p class="text-sm text-fb-text">Nothing waiting for review.</p>' +
|
||||
'<p class="text-xs text-fb-textDim">Medium-confidence matches queue here while the library is matched in the background. Matching options live in Settings → Library.</p></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Amber "what this chart lacks" chips. Album/year come from the library
|
||||
// row; cover art is detected from the art request failing (flagged onto
|
||||
// the song object by the <img> onerror handler, then re-rendered).
|
||||
function missingChips(song) {
|
||||
const missing = [];
|
||||
if (!String(song.album || '').trim()) missing.push('album');
|
||||
if (!String(song.year || '').trim()) missing.push('year');
|
||||
if (song._artMissing) missing.push('cover art');
|
||||
if (!missing.length) return '';
|
||||
return '<div class="flex flex-wrap items-center gap-1 pt-1">' +
|
||||
'<span class="text-xs text-fb-textDim">Missing:</span>' +
|
||||
missing.map((f) => '<span class="text-xs px-1.5 py-0.5 rounded border border-amber-400/40 text-amber-300/90 bg-amber-400/10">' + esc(f) + '</span>').join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Per-candidate "what accepting this gets you": fields the chart lacks
|
||||
// that the candidate supplies, and fields whose DISPLAYED value would
|
||||
// change (never the file).
|
||||
function diffChips(song, cand) {
|
||||
const adds = [];
|
||||
const changes = [];
|
||||
const have = (v) => String(v == null ? '' : v).trim();
|
||||
const differ = (a, b) => have(a) && have(b) && have(a).toLowerCase() !== have(b).toLowerCase();
|
||||
if (have(cand.album)) { if (!have(song.album)) adds.push('album'); else if (differ(song.album, cand.album)) changes.push('album'); }
|
||||
if (have(cand.year)) { if (!have(song.year)) adds.push('year'); else if (differ(song.year, cand.year)) changes.push('year'); }
|
||||
if (cand.genres && cand.genres.length) adds.push('genres');
|
||||
if (have(cand.isrc)) adds.push('ISRC');
|
||||
if (differ(song.artist, cand.artist)) changes.push(have(song.artist) + ' → ' + have(cand.artist));
|
||||
if (differ(song.title, cand.title)) changes.push('title');
|
||||
let html = '';
|
||||
if (adds.length) html += '<span class="text-xs text-fb-good">Adds: ' + esc(adds.join(' · ')) + '</span>';
|
||||
if (changes.length) html += (html ? ' ' : '') + '<span class="text-xs text-fb-textDim">Shows as: ' + esc(changes.join(' · ')) + '</span>';
|
||||
return html ? '<span class="block truncate pt-0.5">' + html + '</span>' : '';
|
||||
}
|
||||
|
||||
function candRowHtml(song, c, i, selected) {
|
||||
const meta = [c.artist, c.album, c.year, fmtDur(c.duration)].filter(Boolean).join(' · ');
|
||||
const pct = c.score != null ? Math.round(c.score * 100) + '%' : '';
|
||||
return '<button data-mr-cand="' + i + '" role="radio" aria-checked="' + (selected ? 'true' : 'false') + '" class="w-full text-left px-3 py-2 rounded-md border ' +
|
||||
(selected ? 'border-fb-primary bg-fb-primary/10' : 'border-fb-border/50 bg-gray-800/50 hover:border-fb-primary/60') + '">' +
|
||||
'<span class="flex items-baseline justify-between gap-2">' +
|
||||
'<span class="text-sm text-fb-text truncate">' + esc(c.title) + '</span>' +
|
||||
'<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) +
|
||||
'</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;
|
||||
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">' +
|
||||
// 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">' +
|
||||
'<div class="min-w-0">' +
|
||||
'<div class="text-base text-fb-text font-medium truncate">' + esc(song.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(sub) + '</div>' +
|
||||
'<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">' +
|
||||
'<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 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">' +
|
||||
'<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 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>' +
|
||||
'<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) {
|
||||
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));
|
||||
// Art failure → flag + re-render once so the "cover art" chip shows.
|
||||
const img = panel.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) => {
|
||||
btn.addEventListener('click', () => {
|
||||
song._sel = Number(btn.getAttribute('data-mr-cand'));
|
||||
renderCurrent();
|
||||
});
|
||||
});
|
||||
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
const cand = (song.candidates || [])[song._sel || 0];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
|
||||
{ recording_id: cand.recording_id });
|
||||
settle(song);
|
||||
});
|
||||
panel.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', () => {
|
||||
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);
|
||||
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
||||
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
|
||||
}
|
||||
|
||||
// Silent-on-success: the chart just leaves the queue and the next one
|
||||
// renders; the last one renders the done state. No toasts, no sounds.
|
||||
function settle(song) {
|
||||
if (_single) { closeModal(); return; } // Fix-match: done means done
|
||||
const i = _queue.indexOf(song);
|
||||
if (i >= 0) _queue.splice(i, 1);
|
||||
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
|
||||
refreshChip();
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
async function runSearch(panel, song) {
|
||||
const input = panel.querySelector('[data-mr-search-input]');
|
||||
const out = panel.querySelector('[data-mr-search-results]');
|
||||
if (!input || !out) return;
|
||||
const qRaw = input.value.trim();
|
||||
if (!qRaw) return;
|
||||
// "Artist – Title" splits on the first dash; a plain phrase searches
|
||||
// as a title, which MusicBrainz handles well enough.
|
||||
const m = qRaw.split(/\s+[–—-]\s+/);
|
||||
const artist = m.length > 1 ? m[0] : '';
|
||||
const title = m.length > 1 ? m.slice(1).join(' - ') : qRaw;
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Searching…</p>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/search?artist=' + enc(artist) +
|
||||
'&title=' + enc(title) + '&filename=' + enc(song.filename));
|
||||
if (r.status === 503) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">MusicBrainz is unavailable — try again later.</p>';
|
||||
return;
|
||||
}
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">No results.</p>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = 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;
|
||||
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.
|
||||
if (status === 412 || (body && body.needs_setup)) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
|
||||
return;
|
||||
}
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
|
||||
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
|
||||
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function post(url, payload) {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {}),
|
||||
});
|
||||
} catch (_) { /* offline — the row simply stays queued */ }
|
||||
}
|
||||
|
||||
// ── Settings → Library → "Metadata matching" card ────────────────────────
|
||||
// Markup lives statically in index.html (the v3 settings pattern); this
|
||||
// wires it. All null-guarded so v2 (which lacks the elements) no-ops.
|
||||
function wireSettingsCard() {
|
||||
const sel = document.getElementById('enrich-threshold');
|
||||
const order = document.getElementById('enrich-review-order');
|
||||
const btn = document.getElementById('enrich-match-now');
|
||||
// Boolean toggles, element id → settings key. enrich-enabled is the
|
||||
// master background switch; the rest are the R1 scraper options
|
||||
// (per-source + per-field auto-apply).
|
||||
const toggles = [
|
||||
['enrich-enabled', 'enrich_enabled'],
|
||||
['enrich-src-musicbrainz', 'enrich_src_musicbrainz'],
|
||||
['enrich-src-caa', 'enrich_src_caa'],
|
||||
['enrich-apply-names', 'enrich_apply_names'],
|
||||
['enrich-apply-year', 'enrich_apply_year'],
|
||||
['enrich-apply-genres', 'enrich_apply_genres'],
|
||||
['enrich-apply-art', 'enrich_apply_art'],
|
||||
// Artist pages (PR-B): the page itself — local-only, default ON.
|
||||
['artist-pages-enabled', 'artist_pages_enabled'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
// Default-OFF toggles load with the opposite absent-key semantic
|
||||
// (checked only when explicitly true): the external-links row is
|
||||
// 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 {
|
||||
const r = await fetch('/api/settings');
|
||||
if (r.ok) {
|
||||
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;
|
||||
// Snap to the nearest offered option.
|
||||
let best = sel.options[0];
|
||||
for (const o of sel.options) {
|
||||
if (Math.abs(Number(o.value) - want) < Math.abs(Number(best.value) - want)) best = o;
|
||||
}
|
||||
if (best) sel.value = best.value;
|
||||
}
|
||||
if (order) {
|
||||
const v = String(cfg.enrich_review_order || 'missing_first');
|
||||
order.value = ['missing_first', 'artist', 'recent'].includes(v) ? v : 'missing_first';
|
||||
}
|
||||
}
|
||||
} catch (_) { /* leave markup defaults */ }
|
||||
refreshChip(); // also fills #enrich-status
|
||||
})();
|
||||
const save = (key, value) => post('/api/settings', { [key]: value });
|
||||
for (const [el, key] of toggles.concat(optInToggles)) {
|
||||
el.addEventListener('change', () => save(key, !!el.checked));
|
||||
}
|
||||
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');
|
||||
if (line) line.textContent = 'Matching…';
|
||||
setTimeout(refreshChip, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Stop the 5s poll when the library screen is left — the progress line and
|
||||
// chip only live in the songs toolbar, so polling off-screen is pure waste
|
||||
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
|
||||
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
|
||||
// this stays self-contained. Same single-guarded-interval invariant as
|
||||
// refreshChip — no double-interval, cleared to null.
|
||||
function wireScreenTeardown() {
|
||||
const sm = window.feedBack;
|
||||
if (!sm || typeof sm.on !== 'function') return;
|
||||
sm.on('screen:changed', (e) => {
|
||||
const id = e && e.detail && e.detail.id;
|
||||
if (id === 'v3-songs') {
|
||||
refreshChip(); // returning while a pass runs re-arms the poll
|
||||
} else if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}, { once: true });
|
||||
} else {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}
|
||||
|
||||
window.__fbMatchReviewChip = refreshChip;
|
||||
window.__fbOpenMatchReview = openModal;
|
||||
window.__fbFixMatch = fixMatch;
|
||||
})();
|
||||
+78
-14
@@ -24,6 +24,18 @@
|
||||
let rafId = null, running = false;
|
||||
let lastMove = 0, lastUpNext = 0;
|
||||
let openPop = null; // { btn, pop }
|
||||
// Hover state over #player-controls, maintained by mouseenter/mouseleave
|
||||
// (wired in start()). tickIdle previously called matches(':hover') every
|
||||
// rAF frame, which forces a style recalc — profiled hot (feedBack perf).
|
||||
let overControls = false;
|
||||
const _onControlsEnter = () => { overControls = true; };
|
||||
const _onControlsLeave = () => { overControls = false; };
|
||||
// Up-Next pill: cached element refs (re-resolved when detached) and
|
||||
// last-written values, so the 6 Hz recompute only touches the DOM when
|
||||
// something actually changed — unconditional textContent/width writes
|
||||
// re-triggered layout every tick.
|
||||
let upnextEls = null; // { pill, nm, eta, fill }
|
||||
let upnextLast = { name: null, eta: null, prog: -1, hidden: null };
|
||||
|
||||
// ── v3 UI signal + plugin-control slot API ───────────────────────────────
|
||||
// Lets plugins detect v3 (window.feedBack.uiVersion === 'v3') and mount
|
||||
@@ -169,28 +181,52 @@
|
||||
}
|
||||
|
||||
// ── Up Next pill ─────────────────────────────────────────────────────────
|
||||
function _upnextRefs() {
|
||||
// Cache refs; re-resolve only when a node detached (screen re-mount).
|
||||
if (!upnextEls || !upnextEls.pill || !upnextEls.pill.isConnected) {
|
||||
const pill = $('v3-upnext');
|
||||
if (!pill) return null;
|
||||
upnextEls = {
|
||||
pill,
|
||||
nm: $('v3-upnext-name'),
|
||||
eta: $('v3-upnext-eta'),
|
||||
fill: $('v3-upnext-bar-fill'),
|
||||
};
|
||||
// Fresh nodes → forget last-written state so everything re-syncs.
|
||||
upnextLast = { name: null, eta: null, prog: -1, hidden: null };
|
||||
}
|
||||
return upnextEls;
|
||||
}
|
||||
function _upnextSetHidden(els, hidden) {
|
||||
if (upnextLast.hidden === hidden) return;
|
||||
upnextLast.hidden = hidden;
|
||||
els.pill.classList.toggle('hidden', hidden);
|
||||
}
|
||||
function updateUpNext() {
|
||||
const pill = $('v3-upnext');
|
||||
if (!pill) return;
|
||||
const els = _upnextRefs();
|
||||
if (!els) return;
|
||||
// Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON).
|
||||
if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; }
|
||||
if (window.feedBack && window.feedBack.showUpNext === false) { _upnextSetHidden(els, true); return; }
|
||||
const hw = window.highway;
|
||||
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
|
||||
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
|
||||
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { pill.classList.add('hidden'); return; }
|
||||
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { _upnextSetHidden(els, true); return; }
|
||||
let next = null;
|
||||
for (let i = 0; i < secs.length; i++) {
|
||||
if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; }
|
||||
}
|
||||
if (!next) { pill.classList.add('hidden'); return; }
|
||||
if (!next) { _upnextSetHidden(els, true); return; }
|
||||
const dt = Math.max(0, next.time - t);
|
||||
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
|
||||
if (nm) nm.textContent = next.name || '—';
|
||||
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
|
||||
// Coarsened eta (whole seconds from 10 s out, 1 decimal inside) +
|
||||
// write-on-change: drops textContent writes (each a layout pass)
|
||||
// from ~6/s to ~1/s.
|
||||
const name = next.name || '—';
|
||||
const etaText = 'in ' + (dt >= 10 ? Math.round(dt) + '' : dt.toFixed(1)) + 's';
|
||||
if (els.nm && name !== upnextLast.name) { upnextLast.name = name; els.nm.textContent = name; }
|
||||
if (els.eta && etaText !== upnextLast.eta) { upnextLast.eta = etaText; els.eta.textContent = etaText; }
|
||||
// Progress bar: fraction of the current section elapsed toward `next`.
|
||||
// Previous boundary is the last section at/before now (else song start).
|
||||
const fill = $('v3-upnext-bar-fill');
|
||||
if (fill) {
|
||||
if (els.fill) {
|
||||
let prevT = 0;
|
||||
for (let i = 0; i < secs.length; i++) {
|
||||
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
|
||||
@@ -198,9 +234,15 @@
|
||||
}
|
||||
const span = next.time - prevT;
|
||||
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
|
||||
fill.style.width = (prog * 100).toFixed(1) + '%';
|
||||
const q = Math.round(prog * 1000) / 1000;
|
||||
if (q !== upnextLast.prog) {
|
||||
upnextLast.prog = q;
|
||||
// scaleX is compositor-only — width writes re-ran layout.
|
||||
// Pairs with transform-origin:left on #v3-upnext-bar-fill.
|
||||
els.fill.style.transform = 'scaleX(' + q + ')';
|
||||
}
|
||||
}
|
||||
pill.classList.remove('hidden');
|
||||
_upnextSetHidden(els, false);
|
||||
}
|
||||
|
||||
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
|
||||
@@ -230,8 +272,8 @@
|
||||
if (!p) return;
|
||||
const playBtn = $('btn-play');
|
||||
const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true';
|
||||
const controls = $('player-controls');
|
||||
const overControls = controls && typeof controls.matches === 'function' && controls.matches(':hover');
|
||||
// overControls maintained by mouseenter/mouseleave (see start()) —
|
||||
// matches(':hover') here forced a per-frame style recalc.
|
||||
// Keep the transport up while paused, hovering it, or a popover is open.
|
||||
if (openPop || overControls || !playing) { lastMove = now(); return; }
|
||||
if (now() - lastMove > IDLE_MS) {
|
||||
@@ -249,6 +291,16 @@
|
||||
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
|
||||
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
|
||||
syncLyricsIcon();
|
||||
// Reconcile the edge-driven hover flag against ground truth at
|
||||
// this throttled cadence (~6 Hz, not per frame). Covers both
|
||||
// failure modes of pure mouseenter/mouseleave tracking: a
|
||||
// missed mouseleave (controls hidden/detached under the
|
||||
// pointer → flag stuck true, transport never auto-hides) and
|
||||
// a re-created #player-controls node whose listeners were
|
||||
// lost (flag stuck false-ish / dead). matches(':hover') on a
|
||||
// detached node is simply false, so this also self-clears.
|
||||
const c = $('player-controls');
|
||||
overControls = !!(c && typeof c.matches === 'function' && c.matches(':hover'));
|
||||
}
|
||||
tickIdle();
|
||||
rafId = requestAnimationFrame(loop);
|
||||
@@ -263,6 +315,12 @@
|
||||
wireRail();
|
||||
p.addEventListener('mousemove', revealChrome);
|
||||
p.addEventListener('touchstart', revealChrome, { passive: true });
|
||||
const c = $('player-controls');
|
||||
if (c) {
|
||||
overControls = typeof c.matches === 'function' && c.matches(':hover');
|
||||
c.addEventListener('mouseenter', _onControlsEnter);
|
||||
c.addEventListener('mouseleave', _onControlsLeave);
|
||||
}
|
||||
const s = $('speed-slider');
|
||||
if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); }
|
||||
updateSpeedViz();
|
||||
@@ -281,6 +339,12 @@
|
||||
p.removeEventListener('touchstart', revealChrome);
|
||||
p.classList.remove('chrome-active', 'chrome-idle');
|
||||
}
|
||||
const c = $('player-controls');
|
||||
if (c) {
|
||||
c.removeEventListener('mouseenter', _onControlsEnter);
|
||||
c.removeEventListener('mouseleave', _onControlsLeave);
|
||||
}
|
||||
overControls = false;
|
||||
closePop();
|
||||
}
|
||||
function syncActivation() {
|
||||
|
||||
+226
-18
@@ -37,26 +37,59 @@
|
||||
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
|
||||
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
|
||||
if (!arts.length) {
|
||||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
|
||||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '</div>';
|
||||
}
|
||||
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
|
||||
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
|
||||
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
|
||||
}
|
||||
|
||||
// The slot's pinned-arrangement INDEX, resolved from the stored NAME
|
||||
// against the slot's current chart (names survive rescans; an index
|
||||
// wouldn't). null = no pin / the name isn't on this chart → full song.
|
||||
function _slotArrIndex(s) {
|
||||
if (!s.arrangement || !Array.isArray(s.arrangements)) return null;
|
||||
const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement));
|
||||
return (m && m.index != null) ? m.index : null;
|
||||
}
|
||||
|
||||
function songRow(s, opts) {
|
||||
opts = opts || {};
|
||||
const handle = opts.draggable
|
||||
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
|
||||
const tuning = s.tuning_name
|
||||
? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
return '<li data-fn="' + esc(s.filename) + '"' + (opts.draggable ? ' draggable="true"' : '') +
|
||||
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group">' +
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
|
||||
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
|
||||
// the work's current keeper when the pinned file is gone) with its
|
||||
// pinned arrangement (data-play-arr); `missing` = the whole work left
|
||||
// the library, so the row dims and loses play (denominator stays
|
||||
// honest). ▾ opens the slot editor (chart + arrangement pin).
|
||||
const isAlbum = !!opts.album;
|
||||
const missing = isAlbum && !!s.missing;
|
||||
const playFn = s.resolved_filename || s.filename;
|
||||
const arrIdx = isAlbum ? _slotArrIndex(s) : null;
|
||||
const playAttrs = isAlbum && !missing
|
||||
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
|
||||
: '';
|
||||
const acc = (isAlbum && typeof opts.acc === 'number')
|
||||
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
|
||||
: '';
|
||||
const pin = (isAlbum && s.arrangement)
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
|
||||
const orphan = (isAlbum && s.resolved_from_orphan)
|
||||
? '<span class="ml-2 text-[0.625rem] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
|
||||
const slotBtn = (isAlbum && !missing)
|
||||
? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
|
||||
return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
|
||||
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group' + (missing ? ' opacity-50' : '') + '">' +
|
||||
handle +
|
||||
'<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
|
||||
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + '</span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span></span>' +
|
||||
'<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>' +
|
||||
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + pin + orphan + '</span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '</span></span>' +
|
||||
acc +
|
||||
(missing ? '' : '<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>') +
|
||||
slotBtn +
|
||||
'<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
|
||||
'</li>';
|
||||
}
|
||||
@@ -68,7 +101,14 @@
|
||||
// playSong decodeURIComponent()s its arg for the highway WS, so
|
||||
// pass an encoded filename (like the rest of v3) — a raw name
|
||||
// with %/#/?/ in it would otherwise misroute or throw.
|
||||
if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
|
||||
// Album slots override the play target (data-play-fn = the
|
||||
// orphan-resolved chart) + pass the pinned arrangement index;
|
||||
// mix/saved rows carry neither attribute and behave as before.
|
||||
const pfn = li.getAttribute('data-play-fn') || fn;
|
||||
const pa = li.getAttribute('data-play-arr');
|
||||
if (typeof window.playSong === 'function') {
|
||||
window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa));
|
||||
}
|
||||
});
|
||||
li.querySelector('[data-remove]')?.addEventListener('click', async () => {
|
||||
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
|
||||
@@ -106,7 +146,10 @@
|
||||
const lists = (await jget('/api/playlists')) || [];
|
||||
root.innerHTML =
|
||||
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
|
||||
'<div class="flex items-center justify-end mb-6">' +
|
||||
'<div class="flex items-center justify-end gap-2 mb-6">' +
|
||||
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
|
||||
// per track — same machinery as a playlist, kind='album'.
|
||||
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
|
||||
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
|
||||
'</div>' +
|
||||
(lists.length
|
||||
@@ -114,7 +157,7 @@
|
||||
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
playlistCoverHtml(p) +
|
||||
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>'
|
||||
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
|
||||
'</div>';
|
||||
@@ -124,6 +167,12 @@
|
||||
await jsend('POST', '/api/playlists', { name });
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => {
|
||||
const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim();
|
||||
if (!name) return;
|
||||
await jsend('POST', '/api/playlists', { name, kind: 'album' });
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelectorAll('[data-pl]').forEach((b) =>
|
||||
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
|
||||
}
|
||||
@@ -134,13 +183,40 @@
|
||||
const pl = await jget('/api/playlists/' + pid);
|
||||
if (!pl) { renderPlaylists(); return; }
|
||||
const isSystem = !!pl.system_key;
|
||||
const isAlbum = pl.kind === 'album';
|
||||
// Set-scoped repertoire (P6, §7.2): an album is a bounded practice SET
|
||||
// with a denominator — "N of M mastered", per-track accuracy, never one
|
||||
// album score. Same 0.9 threshold as the library meter/green badge.
|
||||
let best = {};
|
||||
if (isAlbum) best = (await jget('/api/stats/best')) || {};
|
||||
const slotAcc = (s) => best[s.resolved_filename || s.filename];
|
||||
let meter = '';
|
||||
if (isAlbum && pl.songs.length) {
|
||||
const tracks = pl.songs.filter((s) => !s.missing);
|
||||
const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length;
|
||||
const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length;
|
||||
const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0;
|
||||
meter =
|
||||
'<div class="mb-6">' +
|
||||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||||
'<span class="text-sm font-semibold text-fb-text">Album repertoire</span>' +
|
||||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + tracks.length + ' mastered' +
|
||||
(started ? ' · ' + started + ' in progress' : '') + '</span></div>' +
|
||||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
root.innerHTML =
|
||||
'<div class="max-w-3xl mx-auto p-6 md:p-8">' +
|
||||
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
|
||||
'<div class="flex items-center justify-between mb-6 gap-3">' +
|
||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
|
||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
|
||||
'<div class="flex gap-2 shrink-0 items-center">' +
|
||||
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' : '') +
|
||||
(pl.songs.length
|
||||
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
|
||||
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
|
||||
'</button>' +
|
||||
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
|
||||
: '') +
|
||||
(isSystem ? '' :
|
||||
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
||||
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
|
||||
@@ -149,22 +225,66 @@
|
||||
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
meter +
|
||||
(pl.songs.length
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') +
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||
'</div>';
|
||||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
||||
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
|
||||
// one preference, not per playlist. The queue is shuffled once when
|
||||
// Play starts (playQueue.start's shuffle opt); the stored playlist
|
||||
// order is never touched.
|
||||
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
|
||||
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
|
||||
const paintShuffle = () => {
|
||||
if (!shuffleBtn) return;
|
||||
const on = shuffleOn();
|
||||
shuffleBtn.className = on
|
||||
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
|
||||
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
|
||||
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
|
||||
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
};
|
||||
paintShuffle();
|
||||
shuffleBtn?.addEventListener('click', () => {
|
||||
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
|
||||
paintShuffle();
|
||||
});
|
||||
// Play all: start the play-queue with this playlist's songs (auto-advances
|
||||
// track to track). Falls back to playing the first song on an older core
|
||||
// without the queue, so the button always does something.
|
||||
// without the queue, so the button always does something. An ALBUM plays
|
||||
// each slot's resolved chart with its pinned arrangement (playQueue's
|
||||
// per-index arrangements array, #685) and skips missing works.
|
||||
root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
|
||||
const files = (pl.songs || []).map((s) => s.filename).filter(Boolean);
|
||||
const files = [], arrs = [];
|
||||
(pl.songs || []).forEach((s) => {
|
||||
if (isAlbum && s.missing) return;
|
||||
const fn = s.resolved_filename || s.filename;
|
||||
if (!fn) return;
|
||||
files.push(fn);
|
||||
const idx = isAlbum ? _slotArrIndex(s) : null;
|
||||
arrs.push(idx == null ? undefined : idx);
|
||||
});
|
||||
if (!files.length) return;
|
||||
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: pl.name });
|
||||
else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||
if (window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.start(files, isAlbum
|
||||
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||
: { source: pl.name, shuffle: shuffleOn() });
|
||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
|
||||
if (listEl && isAlbum) {
|
||||
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
|
||||
li.querySelector('[data-slot]')?.addEventListener('click', () => {
|
||||
const fn = li.getAttribute('data-fn');
|
||||
const slot = (pl.songs || []).find((x) => x.filename === fn);
|
||||
if (slot) openSlotPicker(pid, slot, () => renderPlaylistDetail(pid));
|
||||
});
|
||||
});
|
||||
}
|
||||
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
||||
const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
|
||||
if (!name) return;
|
||||
@@ -197,6 +317,94 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Curated-album slot editor (P6, §7.2) ─────────────────────────────────
|
||||
// Pins THIS slot's chart + arrangement. The per-slot pick is deliberately
|
||||
// independent of the work's global preferred — a rehearsed set must stay
|
||||
// the same notes even if the global keeper is re-picked later. Charts come
|
||||
// from the work-charts API; the arrangement pin is stored as a NAME (it
|
||||
// survives rescans; the index is resolved at play). A compact overlay for
|
||||
// now — unifying with the library's Charts drawer (slot-scoped mode) is a
|
||||
// follow-up once the in-flight drawer changes land.
|
||||
async function openSlotPicker(pid, slot, onChange) {
|
||||
const curFn = slot.resolved_filename || slot.filename;
|
||||
let wk = slot.work_key;
|
||||
if (!wk) {
|
||||
const w = await jget('/api/chart/' + encodeURIComponent(curFn) + '/work');
|
||||
wk = w && w.work_key;
|
||||
}
|
||||
const charts = wk ? await jget('/api/work/' + encodeURIComponent(wk) + '/charts') : null;
|
||||
const chartList = (charts && Array.isArray(charts.charts)) ? charts.charts : [];
|
||||
const radio = (name, value, checked, label, sub) =>
|
||||
'<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
|
||||
'<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
|
||||
'<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
|
||||
(sub ? '<span class="block text-[0.625rem] text-fb-textDim truncate">' + sub + '</span>' : '') +
|
||||
'</span></label>';
|
||||
// Checked = the stored pin; an orphaned slot (stored file gone from the
|
||||
// list) pre-checks the chart it currently resolves to, so Apply re-pins
|
||||
// what's actually playing.
|
||||
const slotInList = chartList.some((x) => x.filename === slot.filename);
|
||||
const chartRows = chartList.map((c) => radio(
|
||||
'slot-chart', c.filename,
|
||||
c.filename === slot.filename || (!slotInList && c.filename === curFn),
|
||||
esc(c.title) + (c.is_representative ? ' <span class="text-[0.625rem] text-fb-primary">● preferred</span>' : ''),
|
||||
esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
|
||||
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[0.625rem] text-fb-textDim">(default)</span>', '')]
|
||||
.concat((slot.arrangements || []).map((a) => {
|
||||
const name = (a && (a.smart_name || a.name)) || '';
|
||||
if (!name) return '';
|
||||
return radio('slot-arr', name,
|
||||
slot.arrangement === name || slot.arrangement === a.name,
|
||||
esc(name), '');
|
||||
})).join('');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
|
||||
overlay.innerHTML =
|
||||
'<div class="bg-fb-card w-full max-w-md rounded-xl border border-fb-border/60 p-5 space-y-4 max-h-[80vh] overflow-y-auto v3-scroll">' +
|
||||
'<div class="flex items-center justify-between gap-2">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text truncate">' + esc(slot.title) + '</h3>' +
|
||||
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none" aria-label="Close">✕</button></div>' +
|
||||
(chartRows
|
||||
? '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Chart for this slot</div>' + chartRows + '</div>'
|
||||
: '') +
|
||||
'<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Arrangement</div>' + arrRows + '</div>' +
|
||||
'<div data-err class="hidden text-xs text-red-400"></div>' +
|
||||
'<div class="flex justify-end gap-2">' +
|
||||
'<button type="button" data-cancel class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Cancel</button>' +
|
||||
'<button type="button" data-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Apply</button>' +
|
||||
'</div></div>';
|
||||
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
||||
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('[data-x]').addEventListener('click', close);
|
||||
overlay.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
overlay.querySelector('[data-apply]').addEventListener('click', async () => {
|
||||
const chart = overlay.querySelector('input[name="slot-chart"]:checked');
|
||||
const arr = overlay.querySelector('input[name="slot-arr"]:checked');
|
||||
const body = {};
|
||||
if (chart && chart.value && chart.value !== slot.filename) body.chart_filename = chart.value;
|
||||
if (arr) {
|
||||
const v = arr.value || null;
|
||||
if (v !== (slot.arrangement || null)) body.arrangement = v;
|
||||
}
|
||||
if (Object.keys(body).length) {
|
||||
// jsend → null on a non-2xx (e.g. swap-to-other-work rejected, or
|
||||
// the resolved target duplicates another slot's pin). Surfacing it
|
||||
// and keeping the picker open beats silently closing "as saved".
|
||||
const res = await jsend('PATCH', '/api/playlists/' + pid + '/songs/' + encodeURIComponent(slot.filename), body);
|
||||
if (!res) {
|
||||
const err = overlay.querySelector('[data-err]');
|
||||
if (err) { err.textContent = 'Could not update this slot.'; err.classList.remove('hidden'); }
|
||||
return; // keep the picker open — not a success
|
||||
}
|
||||
}
|
||||
close();
|
||||
onChange();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ── #v3-saved ─────────────────────────────────────────────────────────--
|
||||
async function renderSaved() {
|
||||
const root = document.getElementById('v3-saved');
|
||||
|
||||
@@ -82,10 +82,10 @@
|
||||
'<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
|
||||
'<div class="flex items-end gap-2">' +
|
||||
'<div class="flex flex-col leading-none">' +
|
||||
'<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
|
||||
'<span class="text-gray-400 text-[0.625rem] font-medium">Rank:</span>' +
|
||||
'<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
|
||||
'<div class="flex items-end gap-1">' + bars + '</div>' +
|
||||
'<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
|
||||
'<span class="text-[0.625rem] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
|
||||
'</div></div></button>';
|
||||
// Equipped avatar frame (spec 010 cosmetics).
|
||||
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
|
||||
@@ -178,7 +178,7 @@
|
||||
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
||||
'</div>';
|
||||
const playerIdFooter = (_profile && _profile.player_hash
|
||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
? '<p class="text-center text-[0.625rem] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
: '');
|
||||
root.innerHTML =
|
||||
'<div class="max-w-4xl mx-auto p-6 md:p-8">' +
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-3
@@ -36,7 +36,7 @@
|
||||
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
|
||||
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
|
||||
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
|
||||
{ key: 'songs', screen: 'v3-songs', label: 'Songs', group: 'LIBRARY', icon: 'disc' },
|
||||
{ key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' },
|
||||
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
|
||||
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
|
||||
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
|
||||
@@ -179,7 +179,7 @@
|
||||
const items = NAV.filter((n) => n.group === group);
|
||||
if (!items.length) continue;
|
||||
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[0.625rem] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
||||
}
|
||||
nav.innerHTML = html;
|
||||
@@ -192,6 +192,9 @@
|
||||
}
|
||||
|
||||
// ── Topbar ───────────────────────────────────────────────────────────---
|
||||
// Funding cleared to come back online 2026-06-30 (offending functionality
|
||||
// removed). feedBack-branded Patreon page.
|
||||
const PATREON_URL = 'https://patreon.com/got_feedback';
|
||||
function renderTopbar() {
|
||||
const bar = document.getElementById('v3-topbar');
|
||||
if (!bar) return;
|
||||
@@ -209,6 +212,12 @@
|
||||
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
|
||||
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
|
||||
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
|
||||
// Support Us! — stays on this top utility row (NOT the title row),
|
||||
// pushed to the right with ml-auto; hidden on the smallest widths.
|
||||
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
|
||||
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
|
||||
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
|
||||
'Support Us!</a>' +
|
||||
'</div>' +
|
||||
// Row 2 — page header: title + ONLY the tuner/instrument/profile
|
||||
// badge cluster on the same line as the header.
|
||||
@@ -329,7 +338,8 @@
|
||||
|
||||
// ── Boot ────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
|
||||
var _v3brand = document.getElementById('v3-brand');
|
||||
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
|
||||
renderSidebar();
|
||||
renderTopbar();
|
||||
ensureBackdrop();
|
||||
|
||||
+2142
-163
File diff suppressed because it is too large
Load Diff
+112
-2
@@ -23,7 +23,24 @@
|
||||
const OPACITY = { 95: '0.95', 90: '0.9', 80: '0.8', 70: '0.7', 60: '0.6',
|
||||
50: '0.5', 40: '0.4', 30: '0.3', 20: '0.2', 10: '0.1' };
|
||||
|
||||
// Default fb palette (mirrors tailwind.config.js `fb`). Emitted as
|
||||
// always-present `--fbv-*` on :root so `var(--fbv-accent)` resolves even
|
||||
// un-themed — the un-themed look is unchanged (the fb-* utilities still use
|
||||
// their compiled defaults; this only hands plugins a stable host token to
|
||||
// read + derive from). Adds two keystone ROLES the palette lacked:
|
||||
// `on-accent` (a foreground legible ON the accent fill — the missing piece
|
||||
// behind white-on-accent contrast bugs) and `focus-ring`.
|
||||
const DEFAULTS = {
|
||||
bg: '#0f172a', sidebar: '#111827', card: '#1e293b', cardMuted: '#0b1220',
|
||||
primary: '#0ea5e9', primaryHi: '#38bdf8', accent: '#ef4444',
|
||||
text: '#f8fafc', textDim: '#94a3b8', border: '#334155',
|
||||
good: '#22c55e', mid: '#eab308', low: '#ef4444', gold: '#e8c040',
|
||||
'on-accent': '#f8fafc', 'focus-ring': '#38bdf8',
|
||||
};
|
||||
|
||||
let _frameStyle = ''; // equipped avatar-frame CSS fragment ('' = none)
|
||||
let _themeId = null; // equipped theme id (null = default look)
|
||||
let _themeCaps = null; // equipped theme's declared device capabilities, or null
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
||||
@@ -73,12 +90,14 @@
|
||||
return 'html[data-fb-theme] {\n' + vars + '}\n' + rules;
|
||||
}
|
||||
|
||||
function apply(payload) {
|
||||
function apply(payload, meta) {
|
||||
const colors = payload && payload.colors;
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
if (!colors) {
|
||||
if (styleEl) styleEl.remove();
|
||||
document.documentElement.removeAttribute('data-fb-theme');
|
||||
_themeId = null; _themeCaps = null;
|
||||
_emitThemeChanged();
|
||||
return;
|
||||
}
|
||||
if (!styleEl) {
|
||||
@@ -88,6 +107,13 @@
|
||||
}
|
||||
styleEl.textContent = cssFor(colors);
|
||||
document.documentElement.setAttribute('data-fb-theme', '1');
|
||||
// Track identity + declared device capabilities for the read API. A
|
||||
// theme MAY carry `capabilities` (e.g. {glow:false}) in its payload;
|
||||
// recolor-only themes carry none (→ default affordances reported).
|
||||
_themeId = (meta && meta.id) || (payload && payload.id) || 'custom';
|
||||
_themeCaps = (payload && payload.capabilities)
|
||||
|| (meta && meta.capabilities) || null;
|
||||
_emitThemeChanged();
|
||||
}
|
||||
|
||||
function setFrame(payload) {
|
||||
@@ -106,7 +132,7 @@
|
||||
|
||||
function applyCosmetics(cosmetics) {
|
||||
cosmetics = cosmetics || {};
|
||||
apply((cosmetics.theme || {}).payload || null);
|
||||
apply((cosmetics.theme || {}).payload || null, cosmetics.theme || null);
|
||||
setFrame((cosmetics.avatar_frame || {}).payload || null);
|
||||
}
|
||||
|
||||
@@ -123,6 +149,83 @@
|
||||
} catch (e) { /* offline — keep current look */ }
|
||||
}
|
||||
|
||||
// ── Host theme READ surface (window.feedBack.theme) ──────────────────────
|
||||
// The apply side stays on window.v3Theme; this is the read/capability side
|
||||
// plugins consume so a feature can render correctly under any theme instead
|
||||
// of binding to the one the dev happened to see. See docs/host-theme-contract.md.
|
||||
|
||||
// Always-present `--fbv-*` defaults on :root (see DEFAULTS). Additive: the
|
||||
// un-themed look is unchanged; this only makes var(--fbv-*) resolve so a
|
||||
// plugin can derive surfaces from host tokens whether or not a theme is on.
|
||||
function _injectDefaults() {
|
||||
if (document.getElementById('fb-theme-defaults')) return;
|
||||
let vars = '';
|
||||
for (const key in DEFAULTS) {
|
||||
const rgb = hexToRgb(DEFAULTS[key]);
|
||||
if (rgb) vars += ' --fbv-' + key + ': ' + rgb + ';\n';
|
||||
}
|
||||
const el = document.createElement('style');
|
||||
el.id = 'fb-theme-defaults';
|
||||
el.textContent = ':root {\n' + vars + '}\n';
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return !!(window.matchMedia
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// Live snapshot of the resolved role tokens ("r g b" triplets; use as
|
||||
// rgb(var(--fbv-<key>)) in CSS). Reflects the themed values when a theme is
|
||||
// equipped, the :root defaults otherwise.
|
||||
function _readTokens() {
|
||||
const t = {};
|
||||
try {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
for (const key in DEFAULTS) {
|
||||
const v = cs.getPropertyValue('--fbv-' + key).trim();
|
||||
if (v) t[key] = v;
|
||||
}
|
||||
} catch (e) { /* no computed style available yet */ }
|
||||
return t;
|
||||
}
|
||||
|
||||
// Whether the ACTIVE theme permits decorative DEVICES — the signal a feature
|
||||
// reads to pick a device (glow vs. solid) rather than hardcoding one. Today's
|
||||
// recolor-only themes declare nothing → default affordances; a theme may opt
|
||||
// out via `capabilities` in its payload (e.g. a clean theme = {glow:false}).
|
||||
// `motion` is additionally gated by the OS reduced-motion preference.
|
||||
function capabilities() {
|
||||
const c = _themeCaps || {};
|
||||
const allowMotion = c.motion !== undefined ? !!c.motion : true;
|
||||
return {
|
||||
glow: c.glow !== undefined ? !!c.glow : true,
|
||||
gradients: c.gradients !== undefined ? !!c.gradients : true,
|
||||
motion: allowMotion && !prefersReducedMotion(),
|
||||
};
|
||||
}
|
||||
|
||||
function get() {
|
||||
return {
|
||||
id: _themeId,
|
||||
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
|
||||
tokens: _readTokens(),
|
||||
};
|
||||
}
|
||||
|
||||
function _emitThemeChanged() {
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
window.feedBack.emit('theme:changed', {
|
||||
id: _themeId,
|
||||
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
|
||||
tokens: _readTokens(),
|
||||
capabilities: capabilities(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.v3Theme = {
|
||||
apply, // preview/apply a theme payload directly (null = default)
|
||||
applyCosmetics, // apply a {theme, avatar_frame} equipped map
|
||||
@@ -131,6 +234,13 @@
|
||||
refresh, // re-read equipped cosmetics from /api/profile
|
||||
};
|
||||
|
||||
// Host-owned theme READ surface. Attached defensively so it survives the
|
||||
// feedBack bus being (re)built by capabilities.js regardless of load order
|
||||
// (the bus constructor copies pre-existing keys onto itself).
|
||||
window.feedBack = window.feedBack || {};
|
||||
window.feedBack.theme = { get, capabilities, prefersReducedMotion };
|
||||
|
||||
_injectDefaults();
|
||||
refresh();
|
||||
// Re-apply when an equip/unequip happens anywhere (shop screen, capability
|
||||
// command from a plugin).
|
||||
|
||||
+55
-12
@@ -4,6 +4,12 @@
|
||||
* `fb` palette in tailwind.config.js.
|
||||
*/
|
||||
|
||||
/* Interface-size (Accessibility): the always-present UI-scale token. JS
|
||||
(capabilities/interface-scale.js) overrides it on :root and drives the
|
||||
visible scaling through the root font-size; this default keeps
|
||||
`var(--fb-scale)` resolvable for any canvas/CSS consumer even when unset. */
|
||||
:root { --fb-scale: 1; }
|
||||
|
||||
/* ── Text-selection policy (v3) ──────────────────────────────────────────────
|
||||
Accidental drag/double-click selection of app chrome (sidebar, transport, the
|
||||
note highway/HUD, buttons, labels) makes the UI look broken and is never
|
||||
@@ -367,10 +373,15 @@ input, textarea, select,
|
||||
}
|
||||
#player-hud .v3-upnext .v3-upnext-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
/* Fill is driven via transform: scaleX(0..1) from player-chrome.js —
|
||||
compositor-only, unlike the previous width writes which re-ran
|
||||
layout on every update tick. */
|
||||
width: 100%;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
|
||||
transition: width .12s linear;
|
||||
transition: transform .12s linear;
|
||||
}
|
||||
|
||||
/* — Live performance HUD (top-right, read-only) — */
|
||||
@@ -599,7 +610,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
font-size: 32px;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
#v3-player-rail .section-practice-control--v3 .section-practice-pill-icon .v3-rail-svg,
|
||||
@@ -872,7 +883,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
|
||||
padding: 0 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #22d3ee; color: #0f172a;
|
||||
font-size: 10px; font-weight: 800; line-height: 1;
|
||||
font-size: 0.625rem; font-weight: 800; line-height: 1;
|
||||
border-radius: 999px; z-index: 2;
|
||||
}
|
||||
.v3-rail-badge[hidden] { display: none; }
|
||||
@@ -1114,6 +1125,32 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; }
|
||||
.fb-srow-stack .fb-srow-control input[type="text"] { flex: 1 1 auto; min-width: 0; }
|
||||
.fb-srow-wide { width: 100%; }
|
||||
|
||||
/* Segmented control + fine-tune (Accessibility → Interface size) */
|
||||
.fb-seg {
|
||||
display: inline-flex; flex-wrap: wrap; gap: .25rem;
|
||||
background: #0f172a; border: 1px solid rgba(51, 65, 85, .55);
|
||||
border-radius: .7rem; padding: .3rem;
|
||||
}
|
||||
.fb-seg-btn {
|
||||
appearance: none; border: none; cursor: pointer;
|
||||
padding: .5rem .9rem; min-height: 2rem; border-radius: .5rem;
|
||||
font-size: .82rem; font-weight: 600; color: #94a3b8; background: transparent;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.fb-seg-btn:hover { color: #e2e8f0; }
|
||||
.fb-seg-btn.active { background: #0ea5e9; color: #04263a; }
|
||||
.fb-seg-btn:focus-visible { outline: 2px solid #38bdf8; outline-offset: 2px; }
|
||||
.fb-finetune { margin-top: .6rem; }
|
||||
.fb-finetune > summary {
|
||||
font-size: .78rem; font-weight: 600; color: #94a3b8; cursor: pointer;
|
||||
list-style: none; display: inline-flex; align-items: center; gap: .35rem;
|
||||
}
|
||||
.fb-finetune > summary::-webkit-details-marker { display: none; }
|
||||
.fb-finetune > summary::before { content: "\25B8"; font-size: .7rem; transition: transform .15s; }
|
||||
.fb-finetune[open] > summary::before { transform: rotate(90deg); }
|
||||
.fb-finetune-body { display: flex; align-items: center; gap: 1rem; margin-top: .6rem; }
|
||||
.fb-seg-val { font-size: .8rem; color: #94a3b8; min-width: 3rem; text-align: right; }
|
||||
|
||||
/* Toggle switch */
|
||||
.fb-switch { position: relative; display: inline-block; width: 2.6rem; height: 1.5rem; flex: none; }
|
||||
.fb-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
@@ -1200,14 +1237,14 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
|
||||
.v3-azrail {
|
||||
position: fixed;
|
||||
right: 2px;
|
||||
right: 4px; /* off the very edge so letters aren't clipped */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-height: 84vh;
|
||||
align-items: stretch; /* equal-width buttons → one wide, even hit column */
|
||||
max-height: 92vh;
|
||||
padding: 4px 1px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
@@ -1219,17 +1256,23 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #94a3b8; /* fb-textDim */
|
||||
font-size: .62rem;
|
||||
/* Scale with viewport height so the 27-letter rail grows on tall / hi-res
|
||||
displays (a fixed size looked tiny at 1440p) while still fitting 27 rows
|
||||
within max-height on short screens. */
|
||||
font-size: clamp(.72rem, 1.4vh, 1.05rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
padding: 1px 4px;
|
||||
line-height: 1.04;
|
||||
/* Vertical padding fattens the tap target (was ~13px tall → easy to miss). */
|
||||
padding: clamp(2px, .55vh, 6px) 9px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.v3-azrail-letter:hover:not([disabled]),
|
||||
.v3-azrail-letter.is-active {
|
||||
color: #0ea5e9; /* fb-primary */
|
||||
background: rgba(14, 165, 233, .15); /* visible target under the scrub */
|
||||
}
|
||||
.v3-azrail-letter:focus-visible {
|
||||
outline: 2px solid #38bdf8; /* fb-primaryHi */
|
||||
@@ -1242,7 +1285,7 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
/* Drag indicator bubble (Android fast-scroll pattern). */
|
||||
.v3-azbubble {
|
||||
position: fixed;
|
||||
right: 2.6rem;
|
||||
right: 2.9rem; /* clear the (now wider) rail */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 26;
|
||||
@@ -1264,7 +1307,7 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
|
||||
Tighten it; a collapse-to-anchors pass is a follow-up. */
|
||||
@media (max-height: 640px) {
|
||||
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
|
||||
.v3-azrail-letter { font-size: clamp(.5rem, 1.3vh, .62rem); padding: 0 7px; }
|
||||
}
|
||||
|
||||
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ module.exports = {
|
||||
cardMuted: '#0b1220', // inset wells
|
||||
primary: '#0ea5e9', // sky — primary actions, active nav, progress fill
|
||||
primaryHi: '#38bdf8', // hover
|
||||
accent: '#ef4444', // red — destructive, low-accuracy
|
||||
accent: '#ef4444', // red — Support Us, destructive, low-accuracy
|
||||
text: '#f8fafc', // primary text
|
||||
textDim: '#94a3b8', // secondary text
|
||||
border: '#334155', // hairlines / card borders
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Pins the auto-reframe-on-layout-drift behaviour ported into the drum and
|
||||
// keys 3D highway renderers from plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Bug it guards against: under splitscreen the host resizes each panel's
|
||||
// canvas but overrides hw.resize and never calls renderer.resize(). The
|
||||
// guitar/bass highway_3d self-detects this in draw(); the drum and keys
|
||||
// highways originally only re-framed when the host called resize(w,h), so
|
||||
// their panels did NOT resize when the app went fullscreen — they stayed
|
||||
// framed for the pre-fullscreen size while the guitar/bass panels adapted.
|
||||
//
|
||||
// The fix ports highway_3d's per-frame drift check into both draw() loops:
|
||||
// compare the live canvas backing store (canvas.width/height) AND the CSS box
|
||||
// (clientWidth/Height) against the last applied logical size, re-running
|
||||
// applySize() on either drift. A refactor that drops this check, stops
|
||||
// recording _appliedW/_appliedH, or reverts to resize()-only sizing would
|
||||
// silently bring the fullscreen-split bug back.
|
||||
//
|
||||
// Source-level only — same strategy as highway_3d_resize_reframe.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PLUGINS = path.join(__dirname, '..', '..', 'plugins');
|
||||
const CASES = [
|
||||
{ name: 'drum_highway_3d', file: path.join(PLUGINS, 'drum_highway_3d', 'screen.js') },
|
||||
{ name: 'keys_highway_3d', file: path.join(PLUGINS, 'keys_highway_3d', 'screen.js') },
|
||||
];
|
||||
|
||||
for (const { name, file } of CASES) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
|
||||
test(`${name}: applied-size tracking is declared as instance state`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/let\s+_appliedW\s*=\s*0\s*,\s*_appliedH\s*=\s*0\s*;/,
|
||||
'_appliedW / _appliedH must be declared as per-instance state',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/let\s+_lastHwW\s*=\s*0\s*,\s*_lastHwH\s*=\s*0\s*;/,
|
||||
'_lastHwW / _lastHwH must be declared as per-instance state',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: applySize records the logical w/h it applied`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/_appliedW\s*=\s*[wW]\s*;\s*_appliedH\s*=\s*[hH]\s*;/,
|
||||
'applySize must record _appliedW / _appliedH',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: draw() preserves the backing-store drift branch (splitscreen path)`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/,
|
||||
'the backing-store (canvas.width/height) comparison must run every frame',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_bsChanged\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;[\s\S]*?applySize\(\s*_bw\s*,\s*_bh\s*\)\s*;/,
|
||||
'the backing-store drift branch must re-apply',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: draw() re-applies on CSS-box drift without a backing-store change`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/else if\s*\(\s*_bw\s*>\s*0\s*&&\s*_bh\s*>\s*0\s*&&\s*\(\s*Math\.abs\(\s*_bw\s*-\s*_appliedW\s*\)\s*>\s*1\s*\|\|\s*Math\.abs\(\s*_bh\s*-\s*_appliedH\s*\)\s*>\s*1\s*\)\s*\)\s*\{\s*applySize\(\s*_bw\s*,\s*_bh\s*\)\s*;/,
|
||||
'draw() must re-apply when the live box drifts >1px from _appliedW/_appliedH',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${name}: destroy() resets the applied-size tracking`, () => {
|
||||
assert.match(
|
||||
src,
|
||||
/_lastHwW\s*=\s*0\s*;\s*_lastHwH\s*=\s*0\s*;\s*_appliedW\s*=\s*0\s*;\s*_appliedH\s*=\s*0\s*;/,
|
||||
'destroy() must reset the drift-tracking state to 0',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/\bhandShapes:\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ function src(file) {
|
||||
test('highway renderer bundles surface the core lefty flag', () => {
|
||||
assert.match(
|
||||
src(HIGHWAY_JS),
|
||||
/lefty\s*:\s*_lefty/,
|
||||
/lefty\s*[:=]\s*_lefty/,
|
||||
'custom renderer bundles must include lefty: _lefty',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -61,10 +61,25 @@ test('draw() reads the live canvas box once per frame', () => {
|
||||
test('backing-store drift branch is preserved (splitscreen path)', () => {
|
||||
// The original check that catches the splitscreen hw.resize override
|
||||
// resizing the element without calling renderer.resize() must remain.
|
||||
// The comparison is hoisted into _bsChanged (checked with cheap property
|
||||
// reads every frame, and it forces the throttled box read to run on the
|
||||
// same frame); the branch body is unchanged.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
|
||||
'the backing-store (canvas.width/height) drift branch must still re-apply',
|
||||
/const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/,
|
||||
'the backing-store (canvas.width/height) comparison must run every frame',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_bsChanged\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
|
||||
'the backing-store drift branch must still re-apply',
|
||||
);
|
||||
// The throttle must never delay the backing-store path: _bsChanged is
|
||||
// part of the gate that forces the box read on the same frame.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_bsChanged\s*\|\|\s*!_wrapPinned\s*\|\|\s*_boxCheckCountdown\s*===\s*0\s*\)/,
|
||||
'the box-read gate must include _bsChanged so backing-store changes re-apply immediately',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,13 +43,13 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// Field present in the bundle.
|
||||
assert.match(fn, /\bisPlaying\s*:/, 'bundle must expose isPlaying');
|
||||
assert.match(fn, /\bisPlaying\s*[:=]/, 'bundle must expose isPlaying');
|
||||
// It is computed from the same anchor/advance state getTime() uses, not a
|
||||
// hardcoded literal — anchor must exist AND the clock must have advanced
|
||||
// within the interp cap.
|
||||
assert.match(
|
||||
fn,
|
||||
/isPlaying\s*:\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||
);
|
||||
assert.match(
|
||||
|
||||
@@ -82,7 +82,7 @@ test('draw() only adapts during active playback and feeds the HUD', () => {
|
||||
|
||||
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /renderScale:\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
||||
assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
|
||||
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
|
||||
});
|
||||
|
||||
|
||||
@@ -35,10 +35,11 @@ function buildFacade() {
|
||||
'return _hwcInstallFacade;',
|
||||
].join('\n');
|
||||
const params = [
|
||||
'window', 'HWC_SLOTS', 'console',
|
||||
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
|
||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
'applyHighwayStringColors', 'applyHighwayStringPreset',
|
||||
'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
];
|
||||
|
||||
const listeners = {};
|
||||
@@ -64,14 +65,19 @@ function buildFacade() {
|
||||
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
|
||||
_hwcChartShape: () => ({ sc: 6, isBass: false }),
|
||||
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
|
||||
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
|
||||
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
|
||||
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
|
||||
};
|
||||
const HWC_PRESETS = [
|
||||
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
|
||||
];
|
||||
const installer = new Function(...params, body)(
|
||||
win, HWC_SLOTS, console,
|
||||
win, HWC_SLOTS, HWC_PRESETS, console,
|
||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
|
||||
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||
@@ -87,11 +93,13 @@ test('facade exposes the documented surface', () => {
|
||||
const { api } = buildFacade();
|
||||
assert.equal(api.version, 1);
|
||||
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
|
||||
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
|
||||
}
|
||||
assert.deepEqual(api.slots.map((s) => s.key),
|
||||
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
|
||||
// One-click presets: exposed as detached [{ id, label, colors }] copies.
|
||||
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
|
||||
});
|
||||
|
||||
test('facade read methods delegate to the manager', () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)',
|
||||
const fn = extractBlock(src, 'function _makeBundle()');
|
||||
// The bundle field must point straight at _noteState — not a fresh
|
||||
// arrow each frame (the per-frame allocation the review flagged).
|
||||
assert.match(fn, /getNoteState:\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
|
||||
@@ -64,7 +64,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
// identity-based guards in renderer code.
|
||||
assert.match(
|
||||
fn,
|
||||
/getNoteStateProvider:\s*_getNoteStateProvider\b/,
|
||||
/getNoteStateProvider\s*[:=]\s*_getNoteStateProvider\b/,
|
||||
'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)'
|
||||
);
|
||||
// Sanity: the stable accessor exists per-createHighway-instance
|
||||
|
||||
@@ -74,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
|
||||
// Windows checkout (autocrlf) every line costs one extra char and the
|
||||
// assertion target can fall outside the window.
|
||||
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1200) {
|
||||
|
||||
@@ -40,7 +40,9 @@ test('settings UI exposes tone source select with all options', () => {
|
||||
assert.match(html, /value="external_hardware"/);
|
||||
assert.match(html, /value="spark_control_x"/);
|
||||
assert.match(html, /Live guitar tone source/);
|
||||
assert.match(html, /won’t warn that no internal amp tone is loaded/);
|
||||
// Apostrophe form drifted from the ’ entity to the literal ’ in a
|
||||
// copy pass — accept entity, typographic, or plain apostrophe.
|
||||
assert.match(html, /won(?:’|’|')t warn that no internal amp tone is loaded/);
|
||||
});
|
||||
|
||||
test('player audio rail exposes tone source select', () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ function loadFunctions(sandbox, src) {
|
||||
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
||||
}
|
||||
function _updateSectionPracticeHighlight(ct) {}
|
||||
function _updateEditRegionBtn() {}
|
||||
${extractFunction(src, 'function clearLoop(')}
|
||||
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
||||
${extractFunction(src, 'async function setLoop(')}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// playQueue.peekNext() (queue-advance UX): consumers that render "Up next"
|
||||
// (the results card's countdown strip) need to know WHAT follows without
|
||||
// reaching into queue internals. Extract the playQueue IIFE from app.js and
|
||||
// drive it against a playSong stub.
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function makeQueue() {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const start = src.indexOf('window.feedBack.playQueue = (function () {');
|
||||
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
|
||||
const end = src.indexOf('})();', start);
|
||||
assert.ok(end !== -1, 'playQueue IIFE terminator found');
|
||||
const iife = src.slice(start, end + 5);
|
||||
const played = [];
|
||||
const sandbox = {
|
||||
window: {
|
||||
feedBack: {},
|
||||
playSong: (fn, arr, opts) => played.push({ fn, arr, opts }),
|
||||
fbNotify: null,
|
||||
},
|
||||
encodeURIComponent,
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
|
||||
return { q: sandbox.window.feedBack.playQueue, played };
|
||||
}
|
||||
|
||||
test('peekNext exposes the following track without mutating the queue', () => {
|
||||
const { q, played } = makeQueue();
|
||||
assert.strictEqual(q.peekNext(), null); // idle queue → null
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'My list' });
|
||||
assert.deepStrictEqual(q.peekNext(), { filename: 'b.sloppak', index: 1, total: 3 });
|
||||
assert.deepStrictEqual(q.peekNext(), { filename: 'b.sloppak', index: 1, total: 3 }); // pure
|
||||
assert.strictEqual(played.length, 1); // peeking never plays
|
||||
q.advance();
|
||||
assert.deepStrictEqual(q.peekNext(), { filename: 'c.sloppak', index: 2, total: 3 });
|
||||
q.advance();
|
||||
assert.strictEqual(q.peekNext(), null); // last track → nothing next
|
||||
assert.strictEqual(q.remaining(), 0);
|
||||
});
|
||||
|
||||
test('peekNext is null after clear', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak']);
|
||||
q.clear();
|
||||
assert.strictEqual(q.peekNext(), null);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
|
||||
// at start. Per-slot arrangements must swap in lockstep with their files
|
||||
// (albums pass arrangements aligned by index, #685), the caller's arrays must
|
||||
// not be mutated, and shuffle:false / absent must preserve order. Extract the
|
||||
// playQueue IIFE from app.js and drive it against a playSong stub.
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function makeQueue() {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const start = src.indexOf('window.feedBack.playQueue = (function () {');
|
||||
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
|
||||
const end = src.indexOf('})();', start);
|
||||
assert.ok(end !== -1, 'playQueue IIFE terminator found');
|
||||
const iife = src.slice(start, end + 5);
|
||||
const played = [];
|
||||
const sandbox = {
|
||||
window: {
|
||||
feedBack: {},
|
||||
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
|
||||
fbNotify: null,
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
|
||||
return { q: sandbox.window.feedBack.playQueue, played };
|
||||
}
|
||||
|
||||
function drain(q, played) {
|
||||
while (q.hasNext()) q.advance();
|
||||
return played.map((p) => p.fn);
|
||||
}
|
||||
|
||||
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
|
||||
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
|
||||
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
|
||||
const origRandom = Math.random;
|
||||
try {
|
||||
// Deterministic RNG so the expected order is checkable.
|
||||
let calls = 0;
|
||||
const seq = [0.1, 0.9, 0.5];
|
||||
Math.random = () => seq[calls++ % seq.length];
|
||||
const { q, played } = makeQueue();
|
||||
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
|
||||
const order = drain(q, played);
|
||||
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
|
||||
// Each played file carries the arrangement it started with.
|
||||
played.forEach((p) => {
|
||||
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
|
||||
});
|
||||
} finally {
|
||||
Math.random = origRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test('shuffle can change the order', () => {
|
||||
const origRandom = Math.random;
|
||||
try {
|
||||
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
|
||||
const { q, played } = makeQueue();
|
||||
q.start(['a', 'b', 'c'], { shuffle: true });
|
||||
const order = drain(q, played);
|
||||
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
|
||||
} finally {
|
||||
Math.random = origRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
|
||||
const files = ['a', 'b', 'c'];
|
||||
const arrs = [2, 0, 1];
|
||||
const { q, played } = makeQueue();
|
||||
q.start(files, { arrangements: arrs });
|
||||
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(files, ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(arrs, [2, 0, 1]);
|
||||
|
||||
// shuffle:true must also leave the caller's arrays alone (start slices).
|
||||
const { q: q2 } = makeQueue();
|
||||
q2.start(files, { arrangements: arrs, shuffle: true });
|
||||
assert.deepStrictEqual(files, ['a', 'b', 'c']);
|
||||
assert.deepStrictEqual(arrs, [2, 0, 1]);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
||||
// merely ABSENT from the current /api/plugins response (transient partial
|
||||
// response while the backend's plugin registry is repopulating after a
|
||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||
// _loadedPluginScripts entry survives made the next refetch fail the
|
||||
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
|
||||
// which duplicated the desktop audio_engine's native signal chain. Plugins
|
||||
// the response knows about but that failed hydration are still wiped, as is
|
||||
// junk DOM carrying no plugin id.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||
// nav reset that opens it to the comment introducing the next section.
|
||||
function extractWipeBlock(src) {
|
||||
const start = src.indexOf("navContainer.innerHTML = '';");
|
||||
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
|
||||
const end = src.indexOf('// Plugin settings area hosts', start);
|
||||
assert.ok(end !== -1, 'wipe block end marker not found');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
function makeEl(pluginId, id) {
|
||||
return {
|
||||
dataset: pluginId != null ? { pluginId } : {},
|
||||
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
|
||||
removed: false,
|
||||
remove() {
|
||||
this.removed = true;
|
||||
const idx = this._parent ? this._parent.indexOf(this) : -1;
|
||||
if (idx >= 0) this._parent.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const block = extractWipeBlock(src);
|
||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||
const container = { children: settingsChildren };
|
||||
const sandbox = {
|
||||
navContainer: { innerHTML: 'seed' },
|
||||
mobileNavContainer: { innerHTML: 'seed' },
|
||||
_pluginSettingsContainers: () => [container],
|
||||
respondedIds,
|
||||
alreadyHydrated,
|
||||
document: {
|
||||
querySelectorAll: (sel) => {
|
||||
assert.equal(sel, '.screen[id^="plugin-"]');
|
||||
return screens.slice();
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('plugin absent from the response keeps its settings + screen DOM', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(), // partial response: plugin missing
|
||||
alreadyHydrated: new Set(), // scan loop never saw it either
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
|
||||
assert.equal(screen.removed, false, 'screen must survive a partial response');
|
||||
});
|
||||
|
||||
test('plugin present in the response but not hydrated is wiped', () => {
|
||||
const settings = makeEl('stale_plugin');
|
||||
const screen = makeEl('stale_plugin');
|
||||
runWipe({
|
||||
respondedIds: new Set(['stale_plugin']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, true);
|
||||
assert.equal(screen.removed, true);
|
||||
});
|
||||
|
||||
test('hydrated plugin present in the response is preserved', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(['audio_engine']),
|
||||
alreadyHydrated: new Set(['audio_engine']),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false);
|
||||
assert.equal(screen.removed, false);
|
||||
});
|
||||
|
||||
test('junk DOM without a plugin id is still removed', () => {
|
||||
const junkSettings = makeEl(null);
|
||||
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
|
||||
const junkScreen = makeEl(null, 'plugin-');
|
||||
runWipe({
|
||||
respondedIds: new Set(['whatever']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [junkSettings],
|
||||
screens: [junkScreen],
|
||||
});
|
||||
assert.equal(junkSettings.removed, true);
|
||||
assert.equal(junkScreen.removed, true);
|
||||
});
|
||||
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
|
||||
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
|
||||
// `a` is missing from this response. That happens transiently during a
|
||||
// backend restart (the plugin registry repopulates while HTTP stays up),
|
||||
// so absence is NOT an uninstall signal — the still-loaded plugin must
|
||||
// keep its stylesheet or it renders visible-but-unstyled until it
|
||||
// reappears. Explicit removal still happens via the not-ready/unstyled
|
||||
// paths (tests below).
|
||||
reconcile([plug({ id: 'b' })]);
|
||||
assert.equal(headLinks.length, 1);
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
||||
assert.equal(headLinks.length, 2);
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
||||
|
||||
@@ -42,7 +42,10 @@ function loadClose(sandbox, src) {
|
||||
globalThis.__seekCalls = 0;
|
||||
globalThis.__playSongCalls = 0;
|
||||
globalThis.__clearLoopCalls = 0;
|
||||
globalThis.__queueClearCalls = 0;
|
||||
globalThis.__audioCurrentTimeSets = [];
|
||||
// closeCurrentSong abandons any play-queue before leaving the player.
|
||||
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
|
||||
var audio = {
|
||||
_t: 42,
|
||||
get currentTime() { return this._t; },
|
||||
@@ -75,6 +78,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
|
||||
await sandbox.__closeCurrentSong();
|
||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
|
||||
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
|
||||
assert.equal(sandbox.__restartCalls, 0);
|
||||
assert.equal(sandbox.__seekCalls, 0);
|
||||
assert.equal(sandbox.__playSongCalls, 0);
|
||||
|
||||
@@ -56,14 +56,12 @@ function createTunerSandbox(opts) {
|
||||
if (_u.includes('/config')) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
showFloatingButton: true,
|
||||
visualizationMode: 'default',
|
||||
audioInputMode: 'auto',
|
||||
autoOpenOnTuningChange: autoOpen,
|
||||
lastInstrument: 'guitar-6',
|
||||
lastTuning: 'Standard',
|
||||
freeTune: false,
|
||||
disabledTunings: [],
|
||||
customTunings: {},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ test('refreshRail reads present letters from the stats endpoint (sort-aware)', (
|
||||
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
|
||||
'refreshRail must query /api/library/stats with the active filter params');
|
||||
// Opts into the active-sort breakdown so non-rail callers skip the scan.
|
||||
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
|
||||
assert.match(src, /railParams\s*=\s*\{\s*sort_letters:\s*1\s*\}/,
|
||||
'refreshRail must request the sort_letters breakdown');
|
||||
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
|
||||
'refreshRail must prefer the active-sort breakdown (sort_letters)');
|
||||
@@ -92,3 +92,25 @@ test('the rail supports pointer drag-scrub + keyboard arrows', () => {
|
||||
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
|
||||
'arrow keys must move between present letters');
|
||||
});
|
||||
|
||||
test('pointer taps are driven by pointerdown, not the retarget-prone click', () => {
|
||||
// A tap must seek on pointerdown (pointer capture retargets the follow-up
|
||||
// click to the rail, so resolving a letter from click is unreliable — taps
|
||||
// would no-op, "clicked O, nothing happened").
|
||||
assert.match(src, /addEventListener\('pointerdown'[\s\S]*?seekToY\(/,
|
||||
'pointerdown must seek immediately so a tap lands without a move');
|
||||
// The click handler must ignore pointer-driven clicks (detail >= 1) and only
|
||||
// handle keyboard Enter/Space activation (synthesized click has detail === 0).
|
||||
assert.match(src, /addEventListener\('click'[\s\S]*?e\.detail\s*!==\s*0/,
|
||||
'the rail click handler must guard on e.detail === 0 (keyboard only)');
|
||||
});
|
||||
|
||||
test('drag scrubs seek instantly while taps/keys seek smoothly', () => {
|
||||
assert.match(src, /async function\s+jumpToLetter\s*\(\s*letter\s*,\s*smooth/,
|
||||
'jumpToLetter must take a smooth flag');
|
||||
assert.match(src, /behavior:\s*smooth\s*\?\s*'smooth'\s*:\s*'auto'/,
|
||||
'jumpToLetter must scroll instantly during a drag, smoothly on a tap');
|
||||
// pointermove scrubs with smooth=false so RELEASE lands on the let-go letter.
|
||||
assert.match(src, /addEventListener\('pointermove'[\s\S]*?seekToY\([^;]*?,\s*false\)/,
|
||||
'pointermove must seek with smooth=false (precise drag tracking)');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Pins the v3 Songs favorite-toggle colour swap in static/v3/songs.js.
|
||||
//
|
||||
// One shared wireCards() [data-fav] handler serves BOTH the grid card and the
|
||||
// tree / "List View" row, but the two render sites use different idle colours
|
||||
// (grid = text-white, tree = text-fb-textDim). The handler used to toggle a
|
||||
// hardcoded text-white, so in List View it never removed text-fb-textDim — the
|
||||
// heart changed glyph (♡→♥) but stayed dim and only turned red after a re-search
|
||||
// re-rendered the row (reported macOS+Windows, 0.3.0, open since 06-25). Each
|
||||
// button now declares its idle colour via data-fav-idle and the handler swaps
|
||||
// exactly that class, so only one colour class is ever present.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
|
||||
test('both fav render sites declare their idle colour via data-fav-idle', () => {
|
||||
// Grid card heart idles white; the tree / List View heart idles dim.
|
||||
assert.match(src, /data-fav data-fav-idle="text-white"/,
|
||||
'the grid fav button must declare data-fav-idle="text-white"');
|
||||
assert.match(src, /data-fav data-fav-idle="text-fb-textDim"/,
|
||||
'the tree/List-View fav button must declare data-fav-idle="text-fb-textDim"');
|
||||
});
|
||||
|
||||
test('the shared fav handler swaps the declared idle colour, not a hardcoded one', () => {
|
||||
// Reads the idle colour off the clicked button …
|
||||
assert.match(src, /getAttribute\('data-fav-idle'\)/,
|
||||
'the fav handler must read the idle colour from the button');
|
||||
// … toggles fb-accent (red) on favorite and restores the context idle colour off it.
|
||||
assert.match(src, /classList\.toggle\('text-fb-accent',\s*d\.favorite\)/);
|
||||
assert.match(src, /classList\.toggle\(\s*idle\s*,\s*!d\.favorite\s*\)/,
|
||||
'the fav handler must restore the per-context idle colour (idle), not text-white');
|
||||
// The grid-only hardcoded idle toggle that stranded text-fb-textDim is gone.
|
||||
assert.doesNotMatch(src, /classList\.toggle\('text-white',\s*!d\.favorite\)/,
|
||||
'the hardcoded text-white idle toggle must be removed');
|
||||
});
|
||||
|
||||
test('the drawer fav-sync (_patchCardFav) swaps the declared idle colour too', () => {
|
||||
// Toggling the like from the Song Details drawer patches the rendered card's
|
||||
// heart via _patchCardFav; it must honour each heart's data-fav-idle the same
|
||||
// way the click handler does, or List-View rows keep the dim-heart bug (#654).
|
||||
assert.match(src, /function _patchCardFav[\s\S]*?getAttribute\('data-fav-idle'\)[\s\S]*?classList\.toggle\(\s*idle\s*,\s*!fav\s*\)/,
|
||||
'_patchCardFav must restore the per-context idle colour (idle), not a hardcoded text-white');
|
||||
assert.doesNotMatch(src, /classList\.toggle\('text-white',\s*!fav\)/,
|
||||
'_patchCardFav must not hardcode the text-white idle toggle');
|
||||
});
|
||||
|
||||
test('the fav toggle keeps the in-memory song model in sync', () => {
|
||||
// So a virtualized grid recycle / tree re-render renders the new state,
|
||||
// not a stale favorite=false read from state.songsById.
|
||||
assert.match(src, /song\.favorite\s*=\s*d\.favorite/,
|
||||
'the fav handler must write the new favorite state back onto the song model');
|
||||
});
|
||||
@@ -31,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
|
||||
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
||||
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
||||
// shows), not the per-arrangement recents row, and each filename appears
|
||||
// once — so no green-badged "keep practicing" card and no duplicates.
|
||||
test('the shelf is the server-side practice-suggestions recommender', () => {
|
||||
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
|
||||
// filename, gate on state.accuracy) moved server-side: the growth-edge
|
||||
// recommender gates (not-mastered) + aggregates per song and picks the
|
||||
// arrangement closest to mastery. The client renders its rows as-is.
|
||||
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
|
||||
// A shelf card click opens the row's recommended arrangement, not the
|
||||
// song's default.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
||||
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
||||
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
|
||||
'shelf cards must pass the recommended arrangement to playSong',
|
||||
);
|
||||
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
|
||||
});
|
||||
|
||||
test('the meter + shelf fetch together and a stale render is discarded', () => {
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
|
||||
'the two reads must be issued together (Promise.all), not sequentially');
|
||||
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||
'a stale render must be superseded by a newer one via a token');
|
||||
|
||||
@@ -64,7 +64,9 @@ const helpers = loadTuningHelpers();
|
||||
|
||||
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
|
||||
// The card renderer's row variable was renamed song → shown when grouped
|
||||
// cards landed (the badge reads the representative chart); accept either.
|
||||
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// Guard for the host theme READ surface added to the cosmetics applier
|
||||
// (static/v3/theme-core.js): always-present `--fbv-*` defaults on :root (so a
|
||||
// plugin can read host tokens un-themed), the two keystone roles the palette
|
||||
// lacked (on-accent / focus-ring), and the window.feedBack.theme read API +
|
||||
// theme:changed event. Source-level guards on the contract surface; runtime
|
||||
// behaviour (apply/unequip/defaults-restore/capabilities) is verified
|
||||
// separately by a headless render. First slice of the host theme contract
|
||||
// (got-feedback/feedBack#644).
|
||||
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const TC = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'v3', 'theme-core.js'), 'utf8');
|
||||
|
||||
test('always-present --fbv-* defaults are injected on :root', () => {
|
||||
assert.match(TC, /const DEFAULTS = \{/);
|
||||
assert.match(TC, /function _injectDefaults\(\)/);
|
||||
assert.match(TC, /id = 'fb-theme-defaults'/);
|
||||
assert.match(TC, /':root \{/);
|
||||
// injected before the first profile read so var(--fbv-*) resolves immediately
|
||||
assert.match(TC, /_injectDefaults\(\);\s*\n\s*refresh\(\);/);
|
||||
});
|
||||
|
||||
test('two keystone roles the palette lacked are added as defaults', () => {
|
||||
assert.match(TC, /'on-accent':/);
|
||||
assert.match(TC, /'focus-ring':/);
|
||||
});
|
||||
|
||||
test('window.feedBack.theme read API is exposed (get/capabilities/prefersReducedMotion)', () => {
|
||||
assert.match(TC, /window\.feedBack = window\.feedBack \|\| \{\};/);
|
||||
assert.match(TC, /window\.feedBack\.theme = \{ get, capabilities, prefersReducedMotion \};/);
|
||||
assert.match(TC, /function get\(\)/);
|
||||
assert.match(TC, /function capabilities\(\)/);
|
||||
assert.match(TC, /function prefersReducedMotion\(\)/);
|
||||
});
|
||||
|
||||
test('capabilities reports device affordances and gates motion on reduced-motion', () => {
|
||||
assert.match(TC, /glow:/);
|
||||
assert.match(TC, /gradients:/);
|
||||
assert.match(TC, /motion: allowMotion && !prefersReducedMotion\(\)/);
|
||||
});
|
||||
|
||||
test('apply() tracks theme id + declared capabilities and emits theme:changed', () => {
|
||||
assert.match(TC, /function apply\(payload, meta\)/);
|
||||
assert.match(TC, /_themeId =/);
|
||||
assert.match(TC, /_themeCaps =/);
|
||||
assert.match(TC, /_emitThemeChanged\(\)/);
|
||||
assert.match(TC, /emit\('theme:changed'/);
|
||||
});
|
||||
|
||||
test('the apply side stays on window.v3Theme (read surface is additive)', () => {
|
||||
assert.match(TC, /window\.v3Theme = \{/);
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -7,6 +7,8 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
@@ -26,3 +28,17 @@ def client(tmp_path):
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path)})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_ach_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = ach_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
@@ -5,6 +5,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' /
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as tuner_routes
|
||||
|
||||
|
||||
@@ -22,3 +24,19 @@ def client(config_dir):
|
||||
"unregister_tuning_provider": lambda pid: None,
|
||||
})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_tuner_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these
|
||||
tests, so a runtime `import routes` in a test body resolves correctly
|
||||
regardless of which other plugin's bare-named routes ran first."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = tuner_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
@@ -54,16 +54,21 @@ class TestConfigDefaults:
|
||||
assert body["lastTuning"] == "Standard"
|
||||
assert body["lastInstrument"] == "guitar-6"
|
||||
assert body["audioInputMode"] == "auto"
|
||||
assert body["showFloatingButton"] is True
|
||||
assert body["visualizationMode"] == "default"
|
||||
assert body["customTunings"] == {}
|
||||
assert body["disabledTunings"] == []
|
||||
|
||||
def test_get_does_not_include_default_tunings(self, client):
|
||||
# defaultTunings moved to GET /api/tunings (core tuning.read capability).
|
||||
body = client.get("/api/plugins/tuner/config").json()
|
||||
assert "defaultTunings" not in body
|
||||
|
||||
def test_get_does_not_include_retired_keys(self, client):
|
||||
# disabledTunings + showFloatingButton were retired along with their
|
||||
# settings UI; the config must no longer expose them.
|
||||
body = client.get("/api/plugins/tuner/config").json()
|
||||
assert "disabledTunings" not in body
|
||||
assert "showFloatingButton" not in body
|
||||
|
||||
|
||||
class TestConfigPersistence:
|
||||
def test_partial_update_persisted(self, client):
|
||||
@@ -109,14 +114,22 @@ class TestConfigPersistence:
|
||||
client.post("/api/plugins/tuner/config", json={"autoOpenOnTuningChange": bad})
|
||||
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is False, bad
|
||||
|
||||
def test_disabled_tunings_strips_entries_without_colon(self, client):
|
||||
client.post("/api/plugins/tuner/config", json={
|
||||
"disabledTunings": ["guitar-6:Drop D", "legacy-entry", "bass-4:Standard"]
|
||||
def test_retired_keys_ignored_and_not_persisted(self, client, config_dir):
|
||||
# disabledTunings + showFloatingButton were retired: POSTing them must
|
||||
# not break the request, leak back in the response, or hit the file.
|
||||
r = client.post("/api/plugins/tuner/config", json={
|
||||
"disabledTunings": ["guitar-6:Drop D"],
|
||||
"showFloatingButton": False,
|
||||
"lastTuning": "Drop D",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = client.get("/api/plugins/tuner/config").json()
|
||||
assert "legacy-entry" not in body["disabledTunings"]
|
||||
assert "guitar-6:Drop D" in body["disabledTunings"]
|
||||
assert "bass-4:Standard" in body["disabledTunings"]
|
||||
assert "disabledTunings" not in body
|
||||
assert "showFloatingButton" not in body
|
||||
assert body["lastTuning"] == "Drop D"
|
||||
saved = json.loads((config_dir / "tuner.json").read_text())
|
||||
assert "disabledTunings" not in saved
|
||||
assert "showFloatingButton" not in saved
|
||||
|
||||
def test_custom_tuning_old_format_migrated_on_read(self, client, config_dir):
|
||||
(config_dir / "tuner.json").write_text(json.dumps({
|
||||
|
||||
@@ -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,94 @@
|
||||
"""Tests for the Albums-view follow-up (#689's client half): the feedpak
|
||||
`track`/`disc` fields flowing scanner → songs columns → the `track` sort the
|
||||
album track list orders by."""
|
||||
|
||||
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 / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
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(__import__("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, title, track=None, disc=None, album="The Album",
|
||||
artist="Artist", genre=""):
|
||||
server.meta_db.put(fn, 0, 0, {
|
||||
"title": title, "artist": artist, "album": album, "year": "1990",
|
||||
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
"track_number": track, "disc": disc, "genre": genre,
|
||||
})
|
||||
|
||||
|
||||
def test_sloppak_extract_meta_reads_track_and_disc(server):
|
||||
d = server.DLC_DIR / "a.sloppak"
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(
|
||||
"title: Song\nartist: Artist\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\ntrack: 7\ndisc: 2\n", encoding="utf-8")
|
||||
import sloppak
|
||||
meta = sloppak.extract_meta(d)
|
||||
assert meta["track_number"] == 7
|
||||
assert meta["disc"] == 2
|
||||
# Unauthored → None (the album view falls back to title order).
|
||||
(d / "manifest.yaml").write_text(
|
||||
"title: Song\nartist: Artist\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\n", encoding="utf-8")
|
||||
meta = sloppak.extract_meta(d)
|
||||
assert meta["track_number"] is None
|
||||
assert meta["disc"] is None
|
||||
|
||||
|
||||
def test_track_sort_orders_by_disc_then_track_nulls_last(server, client):
|
||||
_put(server, "d2t1.sloppak", "Zeta", track=1, disc=2)
|
||||
_put(server, "d1t2.sloppak", "Yankee", track=2, disc=1)
|
||||
_put(server, "d1t1.sloppak", "Xray", track=1, disc=1)
|
||||
_put(server, "nonum-b.sloppak", "Bravo") # unauthored → bottom,
|
||||
_put(server, "nonum-a.sloppak", "Alpha") # ordered by title
|
||||
body = client.get("/api/library", params={
|
||||
"artist": "Artist", "album": "The Album", "sort": "track", "size": 50}).json()
|
||||
assert [s["filename"] for s in body["songs"]] == [
|
||||
"d1t1.sloppak", "d1t2.sloppak", "d2t1.sloppak",
|
||||
"nonum-a.sloppak", "nonum-b.sloppak"]
|
||||
|
||||
|
||||
def test_track_and_disc_survive_put_roundtrip(server):
|
||||
_put(server, "a.sloppak", "Song", track=3, disc=1)
|
||||
row = server.meta_db.conn.execute(
|
||||
"SELECT track_number, disc FROM songs WHERE filename = 'a.sloppak'").fetchone()
|
||||
assert row == (3, 1)
|
||||
|
||||
|
||||
def test_albums_endpoint_honours_genre_filter(server, client):
|
||||
"""The albums grid must respect the Genre drawer filter the client sends —
|
||||
without this the /api/library/albums route silently dropped `genre` and
|
||||
surfaced albums with no matching tracks."""
|
||||
_put(server, "rock.sloppak", "Rocker", album="Rock LP", genre="Rock")
|
||||
_put(server, "jazz.sloppak", "Smooth", album="Jazz LP", genre="Jazz")
|
||||
all_albums = client.get("/api/library/albums", params={"artist": "Artist"}).json()
|
||||
assert {a["album"] for a in all_albums["albums"]} == {"Rock LP", "Jazz LP"}
|
||||
filtered = client.get("/api/library/albums",
|
||||
params={"artist": "Artist", "genre": "Rock"}).json()
|
||||
assert [a["album"] for a in filtered["albums"]] == ["Rock LP"]
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Tests for the PR-C cover picker's server side: the /art/candidates
|
||||
assembly (current + pack + Cover Art Archive index candidates), the
|
||||
`caa_index_{id}.json` TTL-less cache around the new `_caa_release_index`
|
||||
seam, the `?source=pack` art-route variant, and the redirect-following
|
||||
art-by-URL fetch that lets a CAA pick apply through the existing
|
||||
override lane.
|
||||
|
||||
Both network seams (`_caa_release_index`, `requests.get` under
|
||||
`_fetch_art_url`) are faked — nothing here opens a socket, and the
|
||||
offline default is itself asserted. Fixture patterns mirror
|
||||
tests/test_art_layer.py.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
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(__import__("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 png_bytes(color=(200, 30, 30)):
|
||||
buf = _io.BytesIO()
|
||||
Image.new("RGB", (4, 4), color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def b64(data):
|
||||
import base64
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
|
||||
d = server.DLC_DIR / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(
|
||||
f"title: {title}\nartist: {artist}\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\n", encoding="utf-8")
|
||||
if with_cover:
|
||||
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
|
||||
server.meta_db.put(name, 0, 0, {
|
||||
"title": title, "artist": artist, "album": "", "year": "",
|
||||
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
return d
|
||||
|
||||
|
||||
def _match_row(server, fn, release_id="rel-1", state="matched"):
|
||||
"""Seed a matched/manual enrichment row with a release id (as the P8
|
||||
matcher would have written)."""
|
||||
song = server.meta_db.enrichment_song_row(fn)
|
||||
h = server.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
server.meta_db.apply_enrichment_match(
|
||||
fn, h, state, source="text", score=1.0,
|
||||
cand={"recording_id": "rec-1", "release_id": release_id,
|
||||
"title": song["title"], "artist": song["artist"]})
|
||||
|
||||
|
||||
def _review_row(server, fn, candidates):
|
||||
"""Seed a review-tier row: no canonical release of its own, releases
|
||||
live only in the stored candidates JSON."""
|
||||
song = server.meta_db.enrichment_song_row(fn)
|
||||
h = server.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
server.meta_db.apply_enrichment_match(
|
||||
fn, h, "review", source="text", score=0.75, candidates=candidates)
|
||||
|
||||
|
||||
def _img(img_id, *, front=False, approved=True, sizes=("500",)):
|
||||
"""One CAA index image dict, with thumbnails for the given size keys."""
|
||||
return {
|
||||
"id": img_id,
|
||||
"front": front,
|
||||
"approved": approved,
|
||||
"types": ["Front"] if front else ["Back"],
|
||||
"image": f"https://caa.example/full/{img_id}.jpg",
|
||||
"thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def caa_index(server, monkeypatch):
|
||||
"""Fake CAA index transport + network flag on (mirrors the art-layer
|
||||
`caa` fixture; this is the picker's own seam)."""
|
||||
calls = []
|
||||
indexes = {
|
||||
"rel-1": {"images": [_img(101, front=True),
|
||||
_img(102, approved=False, sizes=("250",))]},
|
||||
"rel-2": {"images": [_img(201, front=True)]},
|
||||
}
|
||||
|
||||
def fake(release_id):
|
||||
calls.append(release_id)
|
||||
return indexes.get(release_id) # unknown release → None (a CAA 404)
|
||||
fake.calls, fake.indexes = calls, indexes
|
||||
monkeypatch.setattr(server, "_caa_release_index", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def _get(client, fn="a.sloppak"):
|
||||
r = client.get(f"/api/song/{fn}/art/candidates")
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
def _caa(body):
|
||||
return [c for c in body["candidates"] if c["kind"] == "caa"]
|
||||
|
||||
|
||||
def _current(body):
|
||||
return next(c for c in body["candidates"] if c["kind"] == "current")
|
||||
|
||||
|
||||
# ── candidate assembly ────────────────────────────────────────────────────────
|
||||
|
||||
def test_matched_row_lists_index_images(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
body = _get(client)
|
||||
assert body["pending"] is False
|
||||
cur = _current(body)
|
||||
assert cur["provenance"] == "none" # nothing served yet
|
||||
assert not any(c["kind"] == "pack" for c in body["candidates"])
|
||||
caa = _caa(body)
|
||||
assert [c["thumb_url"] for c in caa] == [
|
||||
"https://caa.example/101-500.jpg", # front, 500px
|
||||
"https://caa.example/102-250.jpg", # 250 fallback
|
||||
]
|
||||
assert caa[0]["provenance"] == "matched"
|
||||
assert caa[0]["approved"] is True and caa[1]["approved"] is False
|
||||
assert caa[0]["release_id"] == "rel-1"
|
||||
assert caa_index.calls == ["rel-1"] # one index fetch
|
||||
|
||||
|
||||
def test_review_row_includes_candidate_releases(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_review_row(server, "a.sloppak", [
|
||||
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"},
|
||||
{"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"},
|
||||
{"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe
|
||||
{"recording_id": "rec-4", "title": "Song"}, # no release — skipped
|
||||
])
|
||||
body = _get(client)
|
||||
assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order
|
||||
assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"}
|
||||
assert len(_caa(body)) == 3
|
||||
|
||||
|
||||
def test_rejected_row_skips_caa_fetch(server, client, caa_index):
|
||||
"""A row the user rejected (failed/rejected) has no accepted match, so the
|
||||
picker must not spend the shared CAA budget on its stale candidates. The
|
||||
Current tile still serves; the index seam is never asked."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_review_row(server, "a.sloppak", [
|
||||
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}])
|
||||
assert server.meta_db.set_enrichment_rejected("a.sloppak")
|
||||
body = _get(client)
|
||||
assert _caa(body) == []
|
||||
assert caa_index.calls == []
|
||||
assert _current(body)["kind"] == "current"
|
||||
|
||||
|
||||
def test_unmatched_instant_tiles_only(server, client, caa_index):
|
||||
"""No enrichment row at all → current (+ pack when it exists), empty
|
||||
caa list, and the index seam is never asked."""
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
body = _get(client)
|
||||
kinds = [c["kind"] for c in body["candidates"]]
|
||||
assert kinds == ["current", "pack"]
|
||||
assert _current(body)["provenance"] == "pack"
|
||||
pack = body["candidates"][1]
|
||||
assert pack["thumb_url"].endswith("?source=pack")
|
||||
assert caa_index.calls == []
|
||||
|
||||
|
||||
def test_override_provenance_is_yours(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
||||
body = _get(client)
|
||||
assert _current(body)["provenance"] == "yours"
|
||||
# Pack original stays offered even while the override is what serves.
|
||||
assert any(c["kind"] == "pack" for c in body["candidates"])
|
||||
|
||||
|
||||
def test_offline_empty_caa_list_no_error(server, client):
|
||||
"""Under the plain test env the REAL index seam refuses (offline guard);
|
||||
the endpoint still answers 200 with the instant tiles and caches
|
||||
nothing (a later open retries)."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
body = _get(client)
|
||||
assert _caa(body) == []
|
||||
assert _current(body)["kind"] == "current"
|
||||
assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == []
|
||||
|
||||
|
||||
def test_index_cached_second_call_no_refetch(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
first = _get(client)
|
||||
assert len(caa_index.calls) == 1
|
||||
cache = server.ART_CACHE_DIR / "caa_index_rel-1.json"
|
||||
assert cache.is_file() # TTL-less on-disk cache
|
||||
# Even a changed upstream index is not re-asked — indexes are stable.
|
||||
caa_index.indexes["rel-1"] = {"images": []}
|
||||
second = _get(client)
|
||||
assert len(caa_index.calls) == 1 # no refetch
|
||||
assert _caa(second) == _caa(first)
|
||||
|
||||
|
||||
def test_404_release_cached_as_empty(server, client, caa_index):
|
||||
"""A coverless release (CAA 404 → seam returns None) yields no tiles and
|
||||
is never re-asked either."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-missing")
|
||||
assert _caa(_get(client)) == []
|
||||
assert _caa(_get(client)) == []
|
||||
assert caa_index.calls == ["rel-missing"]
|
||||
|
||||
|
||||
def test_caa_candidates_capped_at_12(server, client, caa_index):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
caa_index.indexes["rel-big"] = {
|
||||
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
|
||||
_match_row(server, "a.sloppak", release_id="rel-big")
|
||||
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
|
||||
|
||||
|
||||
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
|
||||
"""Read-only, but it spends the shared CAA rate budget — blocked in demo
|
||||
like enrichment search/kick."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
|
||||
r = client.get("/api/song/a.sloppak/art/candidates")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"error": "demo mode: read-only"}
|
||||
|
||||
|
||||
def test_unknown_song_404(server, client):
|
||||
assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404
|
||||
|
||||
|
||||
# ── traversal / injection hardening ───────────────────────────────────────────
|
||||
|
||||
def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
|
||||
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
|
||||
yields no images, opens no socket, and writes no cache file — inside the
|
||||
art dir or anywhere else."""
|
||||
art_dir = server._enrichment_art_dir()
|
||||
before = set(art_dir.glob("*"))
|
||||
assert not server._CAA_ID_RE.match("../../etc/x")
|
||||
assert server._caa_index_cached("../../etc/x") == []
|
||||
assert caa_index.calls == [] # the seam was never asked
|
||||
assert set(art_dir.glob("*")) == before # nothing written
|
||||
# And nothing landed at the traversal target beside the cache dir either.
|
||||
assert not (art_dir.parent / "etc").exists()
|
||||
|
||||
|
||||
def test_candidates_route_rejects_traversal_filename(server, client, caa_index):
|
||||
"""A traversal filename resolves outside DLC_DIR → _resolve_dlc_path
|
||||
refuses it, the route 404s, and the CAA seam is never touched."""
|
||||
for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"):
|
||||
r = client.get(f"/api/song/{path}/art/candidates")
|
||||
assert r.status_code == 404, path
|
||||
assert caa_index.calls == []
|
||||
|
||||
|
||||
# ── the ?source=pack serve variant ────────────────────────────────────────────
|
||||
|
||||
def test_pack_source_serves_pack_under_override(server, client):
|
||||
"""The Pack-original tile's thumb must show the pack's own art even while
|
||||
an override is what the plain route serves — and 404 when the song ships
|
||||
no art of its own."""
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
||||
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
||||
r = client.get("/api/song/a.sloppak/art?source=pack")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override
|
||||
make_sloppak(server, "bare.sloppak")
|
||||
assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404
|
||||
|
||||
|
||||
# ── art-by-URL redirect handling (what makes a CAA pick applyable) ────────────
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status, headers=None, chunks=()):
|
||||
self.status_code = status
|
||||
self.headers = headers or {}
|
||||
self._chunks = chunks
|
||||
|
||||
def iter_content(self, _size):
|
||||
return iter(self._chunks)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch):
|
||||
import requests
|
||||
fetched, checked = [], []
|
||||
|
||||
def fake_get(url, **kw):
|
||||
fetched.append(url)
|
||||
assert kw.get("allow_redirects") is False # hops stay manual
|
||||
if "coverartarchive.example" in url:
|
||||
return _FakeResp(307, {"Location": "https://archive.example/img.png"})
|
||||
return _FakeResp(200, chunks=[b"IMGDATA"])
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
lambda u: (checked.append(u), False)[1])
|
||||
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
|
||||
assert data == b"IMGDATA"
|
||||
assert fetched == ["https://coverartarchive.example/release/x/front-500",
|
||||
"https://archive.example/img.png"]
|
||||
assert checked == fetched # every hop was gated
|
||||
|
||||
|
||||
def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
302, {"Location": "http://internal.example/x.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
lambda u: "internal" in u)
|
||||
with pytest.raises(ValueError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
|
||||
|
||||
def test_fetch_art_url_redirect_budget(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
307, {"Location": "https://public.example/next.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
|
||||
with pytest.raises(server.EnrichTransportError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Tests for the R3 art layer: the user>pack>CAA serve chain, user overrides
|
||||
(upload / URL / delete, with GIF kept animated and LOCAL-ONLY), and the
|
||||
enrichment art worker's Cover Art Archive fetch + LRU cache.
|
||||
|
||||
Both network seams (`_caa_http_get`, `_fetch_art_url`) are faked — nothing
|
||||
here opens a socket, and the offline default is itself asserted.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
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(__import__("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 png_bytes(color=(200, 30, 30)):
|
||||
buf = _io.BytesIO()
|
||||
Image.new("RGB", (4, 4), color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def gif_bytes():
|
||||
"""A tiny 2-frame animated GIF."""
|
||||
buf = _io.BytesIO()
|
||||
frames = [Image.new("P", (4, 4), i) for i in (0, 255)]
|
||||
frames[0].save(buf, "GIF", save_all=True, append_images=frames[1:], duration=100)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
|
||||
d = server.DLC_DIR / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(
|
||||
f"title: {title}\nartist: {artist}\nduration: 100\n"
|
||||
"arrangements: []\nstems: []\n", encoding="utf-8")
|
||||
if with_cover:
|
||||
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
|
||||
server.meta_db.put(name, 0, 0, {
|
||||
"title": title, "artist": artist, "album": "", "year": "",
|
||||
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
return d
|
||||
|
||||
|
||||
def b64(data):
|
||||
import base64
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
# ── the serve chain: user > pack > CAA ────────────────────────────────────────
|
||||
|
||||
def test_user_override_beats_pack_art(server, client):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200 # pack art serves
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/png" # the override, not the pack jpeg
|
||||
# Removing the override falls back to pack art. (The route lives under
|
||||
# /api/art — the DELETE /api/song/{path} catch-all would shadow it.)
|
||||
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
|
||||
|
||||
def test_gif_override_kept_animated_and_local_only(server, client):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
raw = gif_bytes()
|
||||
body = client.post("/api/song/a.sloppak/art/upload", json={"image": b64(raw)}).json()
|
||||
assert body == {"ok": True, "kind": "gif"}
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.headers["content-type"] == "image/gif"
|
||||
assert r.content == raw # VERBATIM — animation intact
|
||||
# …and the pack file was never touched (GIF never reaches the feedpak).
|
||||
assert (server.DLC_DIR / "a.sloppak" / "cover.jpg").read_bytes() == png_bytes((10, 200, 10))
|
||||
assert not (server.DLC_DIR / "a.sloppak" / "cover.gif").exists()
|
||||
# A later PNG upload replaces the GIF (one override per song).
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
||||
assert len(server._art_override_paths("a.sloppak")) == 1
|
||||
|
||||
|
||||
def test_bad_upload_rejected(server, client):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
assert "error" in client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(b"GIF89a not really a gif")}).json()
|
||||
assert "error" in client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": b64(b"plain text")}).json()
|
||||
|
||||
|
||||
# ── art by URL ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
||||
body = client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/cover.png"}).json()
|
||||
assert body == {"ok": True, "kind": "png"}
|
||||
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_art_url_validation(server, client, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "ftp://example.com/x.png"}).status_code == 400
|
||||
assert client.post("/api/song/a.sloppak/art/url", json={}).status_code == 400
|
||||
assert client.post("/api/song/ghost.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"}).status_code == 404
|
||||
# Oversize → 400 (the seam raises ValueError at the cap).
|
||||
def _huge(url):
|
||||
raise ValueError("image larger than 10 MB")
|
||||
monkeypatch.setattr(server, "_fetch_art_url", _huge)
|
||||
assert client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"}).status_code == 400
|
||||
|
||||
|
||||
def test_art_url_offline_by_default(server, client):
|
||||
"""The real fetch seam refuses under the test env — pytest can never
|
||||
reach the network even when a test forgets to fake it."""
|
||||
make_sloppak(server, "a.sloppak")
|
||||
r = client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"})
|
||||
assert r.status_code == 502
|
||||
|
||||
|
||||
# ── the enrichment art worker (Cover Art Archive) ─────────────────────────────
|
||||
|
||||
def _match_row(server, fn, release_id="rel-1"):
|
||||
"""Seed a matched enrichment row with a release id (as the P8 matcher
|
||||
would have written) so the art worker picks it up."""
|
||||
song = server.meta_db.enrichment_song_row(fn)
|
||||
h = server.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
server.meta_db.apply_enrichment_match(
|
||||
fn, h, "matched", source="text", score=1.0,
|
||||
cand={"recording_id": "rec-1", "release_id": release_id,
|
||||
"title": song["title"], "artist": song["artist"]})
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def caa(server, monkeypatch):
|
||||
"""Fake CAA transport + network flag on (mirrors the P8 test fixture)."""
|
||||
calls = []
|
||||
art = {"rel-1": png_bytes((60, 60, 200))}
|
||||
|
||||
def fake(release_id):
|
||||
calls.append(release_id)
|
||||
return art.get(release_id)
|
||||
fake.calls, fake.art = calls, art
|
||||
monkeypatch.setattr(server, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def test_caa_fetch_fills_missing_art(server, client, caa):
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["art_state"] == "caa"
|
||||
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
# Settled: the next pass never re-fetches.
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
assert len(caa.calls) == n
|
||||
|
||||
|
||||
def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
||||
make_sloppak(server, "haspack.sloppak", with_cover=True, title="One")
|
||||
make_sloppak(server, "b.sloppak", title="Two")
|
||||
make_sloppak(server, "c.sloppak", title="Three")
|
||||
_match_row(server, "haspack.sloppak")
|
||||
_match_row(server, "b.sloppak") # same release as c
|
||||
_match_row(server, "c.sloppak")
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
|
||||
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
|
||||
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
|
||||
assert len(caa.calls) == 1 # one release → ONE fetch
|
||||
|
||||
|
||||
def test_caa_404_marks_none(server, caa):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-missing")
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
assert len(caa.calls) == n # never re-hammered
|
||||
|
||||
|
||||
def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
|
||||
def _down(release_id):
|
||||
raise server.EnrichTransportError("down")
|
||||
monkeypatch.setattr(server, "_caa_http_get", _down)
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# Network back → next pass completes it.
|
||||
monkeypatch.setattr(server, "_caa_http_get", caa)
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
|
||||
|
||||
def test_offline_default_skips_art_worker(server, monkeypatch):
|
||||
"""Under the plain test env the whole art phase is skipped with the rest
|
||||
of the network work."""
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
assert calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
|
||||
|
||||
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
|
||||
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
||||
make_sloppak(server, "a.sloppak", title="One")
|
||||
make_sloppak(server, "b.sloppak", title="Two")
|
||||
caa.art["rel-2"] = png_bytes((1, 1, 1))
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
_match_row(server, "b.sloppak", release_id="rel-2")
|
||||
server._background_enrich()
|
||||
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
|
||||
# at evicted files were reset to unevaluated.
|
||||
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
|
||||
assert len(caa_files) <= 1
|
||||
states = {fn: server.meta_db.get_enrichment(fn)["art_state"]
|
||||
for fn in ("a.sloppak", "b.sloppak")}
|
||||
assert None in states.values() or list(states.values()).count("caa") <= 1
|
||||
|
||||
|
||||
def test_delete_song_removes_override(server, client):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
assert server._art_override_paths("a.sloppak")
|
||||
r = client.delete("/api/song/a.sloppak")
|
||||
assert r.status_code == 200
|
||||
assert server._art_override_paths("a.sloppak") == []
|
||||
|
||||
|
||||
def test_delete_override_restores_caa_fallback(server, client, caa):
|
||||
"""Removing a user override that had settled the row as 'user' must reset
|
||||
the enrichment state so the CAA fallback is fetched and served again —
|
||||
otherwise the song is stranded with no art at all."""
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak")
|
||||
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
|
||||
# Remove it → the row resets to unevaluated…
|
||||
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# …and the next pass fetches + serves the release's front cover.
|
||||
server._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
|
||||
|
||||
def test_upload_rejects_unknown_song_and_oversize(server, client):
|
||||
# Unknown filename → 404 (no stray override file written).
|
||||
assert client.post("/api/song/ghost.sloppak/art/upload",
|
||||
json={"image": b64(png_bytes())}).status_code == 404
|
||||
assert server._art_override_paths("ghost.sloppak") == []
|
||||
# Oversize decoded payload → 400 (bounds the base64 upload path).
|
||||
make_sloppak(server, "a.sloppak")
|
||||
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": huge}).status_code == 400
|
||||
|
||||
|
||||
def test_fetch_art_url_blocks_internal_hosts(server):
|
||||
"""The SSRF guard refuses loopback / link-local / private targets before
|
||||
any request is made (the real seam, not the faked one)."""
|
||||
assert server._url_host_is_internal("http://127.0.0.1/x.png")
|
||||
assert server._url_host_is_internal("http://localhost/x.png")
|
||||
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
||||
assert server._url_host_is_internal("http://10.0.0.5/x.png")
|
||||
assert server._url_host_is_internal("http://[::1]/x.png")
|
||||
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
||||
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Tests for artist-name canonicalization (P4): the artist_alias override that
|
||||
merges messy artist tags ("ACDC" → "AC/DC") AT DISPLAY — the deduped dropdown/
|
||||
tree (query_artists), the artist filter (canonical matches all raw variants),
|
||||
and the grid card label — without rewriting songs.artist or the feedpak files.
|
||||
Sort/A–Z stay on the raw artist (keyset-safe); that reindex is deferred to P5a."""
|
||||
|
||||
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(__import__("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 _seed(server, fn, artist, album="Alb"):
|
||||
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": artist, "album": album})
|
||||
|
||||
|
||||
def _artist_names(client):
|
||||
data = client.get("/api/library/artists?size=100").json()
|
||||
return [a["name"] for a in data["artists"]]
|
||||
|
||||
|
||||
def _grid_artist(client, fn):
|
||||
row = next(s for s in client.get("/api/library").json()["songs"] if s["filename"] == fn)
|
||||
return row["artist"]
|
||||
|
||||
|
||||
def _alias(client, raw, canonical):
|
||||
return client.post("/api/artist-aliases", json={"raw_name": raw, "canonical_name": canonical})
|
||||
|
||||
|
||||
# ── No aliases: raw names, unchanged behaviour ───────────────────────────────
|
||||
|
||||
def test_no_aliases_lists_raw_distinct(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "AC/DC")
|
||||
assert set(_artist_names(client)) == {"ACDC", "AC/DC"}
|
||||
|
||||
|
||||
# ── Dropdown/tree dedupe on the canonical name ───────────────────────────────
|
||||
|
||||
def test_alias_dedupes_artist_list(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "AC/DC")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
data = client.get("/api/library/artists?size=100").json()
|
||||
assert [a["name"] for a in data["artists"]] == ["AC/DC"]
|
||||
assert data["total_artists"] == 1
|
||||
|
||||
|
||||
# ── Grid card shows the canonical label ──────────────────────────────────────
|
||||
|
||||
def test_grid_shows_canonical_artist(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
assert _grid_artist(client, "a.archive") == "AC/DC"
|
||||
|
||||
|
||||
# ── Filtering by the canonical matches every raw variant ─────────────────────
|
||||
|
||||
def test_filter_by_canonical_matches_all_variants(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "AC/DC")
|
||||
_seed(server, "c.archive", "Other")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
got = {s["filename"] for s in
|
||||
client.get("/api/library", params={"artist": "AC/DC"}).json()["songs"]}
|
||||
assert got == {"a.archive", "b.archive"}
|
||||
|
||||
|
||||
# ── Merge endpoint ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_merge_endpoint(client, server):
|
||||
_seed(server, "a.archive", "Beatles")
|
||||
_seed(server, "b.archive", "The Beatles")
|
||||
r = client.post("/api/artist-aliases/merge",
|
||||
json={"raw_names": ["Beatles", "The Beatles"], "canonical_name": "The Beatles"})
|
||||
assert r.json()["merged"] == 1 # "The Beatles" self-skip
|
||||
assert _artist_names(client) == ["The Beatles"]
|
||||
|
||||
|
||||
def test_merge_requires_canonical_and_list(client, server):
|
||||
assert client.post("/api/artist-aliases/merge", json={"raw_names": ["x"]}).status_code == 400
|
||||
assert client.post("/api/artist-aliases/merge", json={"canonical_name": "y"}).status_code == 400
|
||||
|
||||
|
||||
# ── Un-merge: self-alias clears + DELETE ─────────────────────────────────────
|
||||
|
||||
def test_self_alias_clears(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
_alias(client, "ACDC", "ACDC") # self → un-merge
|
||||
assert client.get("/api/artist-aliases").json()["aliases"] == []
|
||||
assert _grid_artist(client, "a.archive") == "ACDC"
|
||||
|
||||
|
||||
def test_delete_alias_unmerges(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
assert client.delete("/api/artist-aliases/ACDC").json()["ok"] is True
|
||||
assert _grid_artist(client, "a.archive") == "ACDC"
|
||||
|
||||
|
||||
# ── Never purged when songs churn (separate, non-filename-keyed table) ────────
|
||||
|
||||
def test_alias_survives_song_reindex(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
# A rescan re-indexes the song (INSERT OR REPLACE INTO songs).
|
||||
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "ACDC", "album": "Alb"})
|
||||
assert _grid_artist(client, "a.archive") == "AC/DC"
|
||||
assert len(client.get("/api/artist-aliases").json()["aliases"]) == 1
|
||||
|
||||
|
||||
# ── Raw-artist picker (Tidy-up source) ───────────────────────────────────────
|
||||
|
||||
def test_raw_artists_lists_counts_and_canonical(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "ACDC")
|
||||
_seed(server, "c.archive", "AC/DC")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
by_name = {a["name"]: a for a in client.get("/api/artists/raw").json()["artists"]}
|
||||
assert by_name["ACDC"]["count"] == 2
|
||||
assert by_name["ACDC"]["canonical"] == "AC/DC" # shows where it maps
|
||||
assert by_name["AC/DC"]["count"] == 1
|
||||
|
||||
|
||||
# ── Transitive chains flatten so sequential merges unify (PR #705 P2) ─────────
|
||||
|
||||
def test_sequential_merge_flattens_transitive_chain(client, server):
|
||||
"""merge ACDC→AC/DC then AC/DC→AC-DC must unify ALL variants onto the terminal
|
||||
canonical ("AC-DC") — not leave a two-hop chain that grouping/filtering split."""
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "AC/DC")
|
||||
_seed(server, "c.archive", "AC-DC")
|
||||
client.post("/api/artist-aliases/merge",
|
||||
json={"raw_names": ["ACDC"], "canonical_name": "AC/DC"})
|
||||
client.post("/api/artist-aliases/merge",
|
||||
json={"raw_names": ["AC/DC"], "canonical_name": "AC-DC"})
|
||||
# Both original variants resolve (single hop) to the terminal canonical.
|
||||
assert server.meta_db.effective_artist("ACDC") == "AC-DC"
|
||||
assert server.meta_db.effective_artist("AC/DC") == "AC-DC"
|
||||
# The song tagged "ACDC" displays the terminal, not the intermediate.
|
||||
assert _grid_artist(client, "a.archive") == "AC-DC"
|
||||
# Grouping shows ONE canonical, not two split groups.
|
||||
assert _artist_names(client) == ["AC-DC"]
|
||||
# Stored rows are already terminal (forward-flattened), never AC/DC.
|
||||
canon = {a["canonical_name"] for a in client.get("/api/artist-aliases").json()["aliases"]}
|
||||
assert canon == {"AC-DC"}
|
||||
# Filtering by the terminal matches every original variant.
|
||||
got = {s["filename"] for s in
|
||||
client.get("/api/library", params={"artist": "AC-DC"}).json()["songs"]}
|
||||
assert got == {"a.archive", "b.archive", "c.archive"}
|
||||
|
||||
|
||||
def test_cycle_is_refused_and_state_intact(client, server):
|
||||
"""A→B then B→A would close a cycle: the second set is refused (409) and the
|
||||
existing A→B mapping is left intact — no loop, no corruption."""
|
||||
_seed(server, "a.archive", "A")
|
||||
_seed(server, "b.archive", "B")
|
||||
client.post("/api/artist-aliases/merge", json={"raw_names": ["A"], "canonical_name": "B"})
|
||||
r = _alias(client, "B", "A") # B → A closes the cycle
|
||||
assert r.status_code == 409
|
||||
# State unchanged: exactly one alias row, A → B.
|
||||
assert client.get("/api/artist-aliases").json()["aliases"] == [
|
||||
{"raw_name": "A", "canonical_name": "B", "mb_artist_id": None}]
|
||||
assert server.meta_db.effective_artist("A") == "B"
|
||||
assert server.meta_db.effective_artist("B") == "B"
|
||||
|
||||
|
||||
def test_terminal_resolution_survives_a_stored_cycle(server):
|
||||
"""Even if the table somehow holds a direct cycle (P↔Q), the visited-set makes
|
||||
_terminal_canonical terminate instead of looping forever."""
|
||||
db = server.meta_db
|
||||
with db._lock:
|
||||
db.conn.execute("INSERT INTO artist_alias (raw_name, canonical_name, updated_at) "
|
||||
"VALUES ('P', 'Q', datetime('now'))")
|
||||
db.conn.execute("INSERT INTO artist_alias (raw_name, canonical_name, updated_at) "
|
||||
"VALUES ('Q', 'P', datetime('now'))")
|
||||
db.conn.commit()
|
||||
assert db._terminal_canonical("P") in ("P", "Q")
|
||||
assert db._terminal_canonical("Q") in ("P", "Q")
|
||||
|
||||
|
||||
def test_list_aliases_sorted(client, server):
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "guns n roses")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
_alias(client, "guns n roses", "Guns N' Roses")
|
||||
aliases = client.get("/api/artist-aliases").json()["aliases"]
|
||||
assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"}
|
||||
|
||||
|
||||
# ── Search (q) matches merged aliases (launch polish) ─────────────────────────
|
||||
|
||||
def _search(client, q):
|
||||
return {s["filename"] for s in
|
||||
client.get("/api/library", params={"q": q}).json()["songs"]}
|
||||
|
||||
|
||||
def test_search_canonical_finds_raw_variants(client, server):
|
||||
"""Searching the canonical name must also find songs whose raw tag is a
|
||||
merged variant — after ACDC→AC/DC, q="AC/DC" returns both."""
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "AC/DC")
|
||||
_seed(server, "c.archive", "Other")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
assert _search(client, "AC/DC") == {"a.archive", "b.archive"}
|
||||
|
||||
|
||||
def test_search_partial_canonical_finds_raw_variants(client, server):
|
||||
"""The alias term is a LIKE, matching the substring semantics of the
|
||||
plain artist term."""
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "Other")
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
assert _search(client, "c/d") == {"a.archive"}
|
||||
|
||||
|
||||
def test_search_without_aliases_unchanged(client, server):
|
||||
"""No aliases → the fast path keeps the original 3-term search."""
|
||||
_seed(server, "a.archive", "ACDC")
|
||||
_seed(server, "b.archive", "AC/DC")
|
||||
assert _search(client, "ACDC") == {"a.archive"}
|
||||
|
||||
|
||||
def test_search_title_album_unaffected_by_alias_term(client, server):
|
||||
"""With aliases present (extra placeholder appended), title/album search
|
||||
still works — guards the parameter order."""
|
||||
_seed(server, "a.archive", "ACDC") # title "a"
|
||||
_alias(client, "ACDC", "AC/DC")
|
||||
server.meta_db.put("t.archive", 0, 0,
|
||||
{"title": "Thunder Road", "artist": "Boss", "album": "Born"})
|
||||
assert _search(client, "Thunder") == {"t.archive"}
|
||||
assert _search(client, "Born") == {"t.archive"}
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Server tests for the artist-pages layer (PR-B, artist-pages launch charrette).
|
||||
|
||||
Two halves, mirroring the design's split:
|
||||
|
||||
* GET /api/artist/{name}/page — the all-LOCAL payload. Covers the counts /
|
||||
albums / alias variants, the DENOMINATOR LAW (mastered counts songs YOU OWN,
|
||||
never anything external — locked position 2), similar-in-library genre
|
||||
co-occurrence (in-library artists only, self excluded, empty → empty), and
|
||||
mb_artist_id resolution from matched/manual rows only.
|
||||
|
||||
* GET /api/artist/{name}/links + POST .../links/refresh — the lazy, cached,
|
||||
opt-in external-links layer. The HTTP transport is a fake over
|
||||
`server._mb_http_get` (the ONE network seam — same pattern as
|
||||
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
|
||||
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
|
||||
resource never reaches a link slot), cache-hit second calls making no
|
||||
network call, the offline guard, the default-OFF setting gate, and the
|
||||
demo-mode blocks.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
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(__import__("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)
|
||||
|
||||
|
||||
MBID = "66c662b6-6e2f-4930-8610-912e24c63ed1"
|
||||
|
||||
|
||||
def _put(server, fn, title=None, artist="AC/DC", album="", year="",
|
||||
genre="", duration=200):
|
||||
server.meta_db.put(fn, 0, 0, {
|
||||
"title": title or fn.split(".")[0], "artist": artist, "album": album,
|
||||
"year": year, "genre": genre, "duration": duration,
|
||||
"arrangements": [{"name": "Lead", "index": 0}],
|
||||
})
|
||||
|
||||
|
||||
def _pin_match(server, fn, artist_id=MBID):
|
||||
"""Give a song a user-pinned (manual) match carrying an artist MBID."""
|
||||
assert server.meta_db.set_enrichment_manual(fn, {
|
||||
"recording_id": "rec-1", "title": "T", "artist": "AC/DC",
|
||||
"artist_id": artist_id,
|
||||
})
|
||||
|
||||
|
||||
def _page(client, name="AC/DC"):
|
||||
r = client.get("/api/artist/" + quote(name, safe="") + "/page")
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
class FakeMBArtist:
|
||||
"""Canned MusicBrainz artist lookup over the _mb_http_get seam."""
|
||||
|
||||
def __init__(self, srv):
|
||||
self._srv = srv
|
||||
self.calls = []
|
||||
self.doc = artist_doc()
|
||||
self.raise_transport = False
|
||||
|
||||
def __call__(self, path, params):
|
||||
if self.raise_transport:
|
||||
raise self._srv.EnrichTransportError("fake network down")
|
||||
self.calls.append((path, dict(params)))
|
||||
if path == f"artist/{MBID}":
|
||||
return self.doc
|
||||
raise AssertionError(f"unexpected MB path {path!r}")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mb_artist(server, monkeypatch):
|
||||
"""Install the fake transport AND enable the network flag (the test env
|
||||
disables it by default — see test_links_offline_returns_empty)."""
|
||||
fake = FakeMBArtist(server)
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def artist_doc():
|
||||
"""An MB artist doc exercising the whole whitelist: a hostile javascript:
|
||||
URL and an ftp:// URL (both must be scheme-gated out), non-whitelisted rel
|
||||
types (must be dropped), one of each slot, and both wiki rels (wikipedia
|
||||
must win over wikidata)."""
|
||||
rel = lambda rtype, url: {"type": rtype, "url": {"resource": url}}
|
||||
return {
|
||||
"id": MBID,
|
||||
"name": "AC/DC",
|
||||
"relations": [
|
||||
rel("official homepage", "javascript:alert(1)"), # scheme-gated
|
||||
rel("official homepage", "https://www.acdc.com"), # first valid wins
|
||||
rel("official homepage", "https://second.example"),
|
||||
rel("setlistfm", "https://www.setlist.fm/setlists/acdc"),
|
||||
rel("youtube", "https://www.youtube.com/acdc"),
|
||||
rel("social network", "https://www.instagram.com/acdc"),
|
||||
rel("bandcamp", "ftp://bad.example/acdc"), # scheme-gated
|
||||
rel("soundcloud", "https://soundcloud.com/acdc"),
|
||||
rel("wikidata", "https://www.wikidata.org/wiki/Q27593"),
|
||||
rel("wikipedia", "https://en.wikipedia.org/wiki/AC/DC"),
|
||||
rel("streaming", "https://stream.example/acdc"), # not whitelisted
|
||||
rel("purchase for download", "https://store.example"), # not whitelisted
|
||||
],
|
||||
"genres": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 5}],
|
||||
}
|
||||
|
||||
|
||||
def _enable_links(client):
|
||||
r = client.post("/api/settings", json={"artist_external_links": True})
|
||||
assert r.status_code == 200 and "error" not in r.json()
|
||||
|
||||
|
||||
# ── /page: counts, albums, variants ──────────────────────────────────────────
|
||||
|
||||
def test_page_counts_albums_and_files(client, server):
|
||||
_put(server, "a.sloppak", album="The Razors Edge", year="1990")
|
||||
_put(server, "b.sloppak", album="The Razors Edge", year="1990")
|
||||
_put(server, "c.sloppak", album="Back in Black", year="1980")
|
||||
_put(server, "d.sloppak", album="") # loose, no album
|
||||
_put(server, "x.sloppak", artist="Other Band", album="Elsewhere")
|
||||
page = _page(client)
|
||||
assert page["artist"] == "AC/DC"
|
||||
assert page["song_count"] == 4 # never the other artist
|
||||
assert page["album_count"] == 2 # empty album ≠ an album
|
||||
albums = {a["name"]: a for a in page["albums"]}
|
||||
assert albums["The Razors Edge"]["count"] == 2
|
||||
assert albums["The Razors Edge"]["year"] == "1990"
|
||||
assert albums["Back in Black"]["count"] == 1
|
||||
assert set(page["files"]) == {"a.sloppak", "b.sloppak", "c.sloppak", "d.sloppak"}
|
||||
# Mosaic art comes from the artist's own songs.
|
||||
assert page["art_urls"] and all("/art" in u for u in page["art_urls"])
|
||||
|
||||
|
||||
def test_page_unknown_artist_is_zero_count_not_error(client, server):
|
||||
page = _page(client, "Nobody Here")
|
||||
assert page["artist"] == "Nobody Here"
|
||||
assert page["song_count"] == 0
|
||||
assert page["albums"] == [] and page["similar"] == []
|
||||
assert page["mb_artist_id"] is None
|
||||
|
||||
|
||||
def test_page_canonicalizes_aliases_and_lists_variants(client, server):
|
||||
_put(server, "a.sloppak", artist="ACDC", album="Alb")
|
||||
_put(server, "b.sloppak", artist="AC/DC", album="Alb")
|
||||
r = client.post("/api/artist-aliases",
|
||||
json={"raw_name": "ACDC", "canonical_name": "AC/DC"})
|
||||
assert r.status_code == 200
|
||||
# Asking by the RAW name lands on the same canonical page.
|
||||
for name in ("AC/DC", "ACDC"):
|
||||
page = _page(client, name)
|
||||
assert page["artist"] == "AC/DC"
|
||||
assert page["song_count"] == 2 # both variants counted
|
||||
assert page["variants"] == [{"name": "ACDC", "count": 1}]
|
||||
|
||||
|
||||
# ── /page: the denominator law ────────────────────────────────────────────────
|
||||
|
||||
def test_mastered_counts_only_owned_songs(client, server):
|
||||
"""Locked position 2: 'N mastered' is over songs in YOUR library — a
|
||||
song_stats row whose file left the library can never inflate it."""
|
||||
_put(server, "a.sloppak")
|
||||
_put(server, "b.sloppak")
|
||||
_put(server, "c.sloppak")
|
||||
server.meta_db.record_session("a.sloppak", 0, score=100, accuracy=0.95) # mastered
|
||||
server.meta_db.record_session("b.sloppak", 0, score=50, accuracy=0.5) # in progress
|
||||
# A mastered score for a song NOT in the library (deleted / renamed) —
|
||||
# must not count: the denominator is ownership.
|
||||
server.meta_db.record_session("gone.sloppak", 0, score=100, accuracy=0.99)
|
||||
page = _page(client)
|
||||
assert page["song_count"] == 3
|
||||
assert page["mastered_count"] == 1
|
||||
assert page["has_stats"] is True
|
||||
|
||||
|
||||
def test_mastered_uses_best_accuracy_across_arrangements(client, server):
|
||||
_put(server, "a.sloppak")
|
||||
server.meta_db.record_session("a.sloppak", 0, score=10, accuracy=0.4)
|
||||
server.meta_db.record_session("a.sloppak", 1, score=90, accuracy=0.93)
|
||||
assert _page(client)["mastered_count"] == 1
|
||||
|
||||
|
||||
def test_no_practice_data_reports_zero_and_flag(client, server):
|
||||
"""The frontend omits the mastered segment when it is 0 (invitational —
|
||||
never '0 mastered'); the payload carries the honest numbers + flag."""
|
||||
_put(server, "a.sloppak")
|
||||
page = _page(client)
|
||||
assert page["mastered_count"] == 0
|
||||
assert page["has_stats"] is False
|
||||
|
||||
|
||||
# ── /page: similar-in-library ─────────────────────────────────────────────────
|
||||
|
||||
def test_similar_ranks_genre_overlap_in_library_only(client, server):
|
||||
_put(server, "a1.sloppak", artist="AC/DC", genre="Rock")
|
||||
_put(server, "a2.sloppak", artist="AC/DC", genre="Blues")
|
||||
_put(server, "b1.sloppak", artist="Band B", genre="rock") # case folds
|
||||
_put(server, "b2.sloppak", artist="Band B", genre="Blues") # 2 shared genres
|
||||
_put(server, "c1.sloppak", artist="Band C", genre="Rock") # 1 shared genre
|
||||
_put(server, "d1.sloppak", artist="Band D", genre="Jazz") # no overlap
|
||||
similar = _page(client)["similar"]
|
||||
names = [s["artist"] for s in similar]
|
||||
assert names[0] == "Band B" # most shared genres
|
||||
assert "Band C" in names
|
||||
assert "Band D" not in names # never non-overlapping
|
||||
assert "AC/DC" not in names # never self
|
||||
|
||||
|
||||
def test_similar_empty_without_genre_data(client, server):
|
||||
_put(server, "a.sloppak", genre="")
|
||||
_put(server, "b.sloppak", artist="Band B", genre="Rock")
|
||||
assert _page(client)["similar"] == []
|
||||
|
||||
|
||||
def test_similar_folds_alias_variants(client, server):
|
||||
_put(server, "a.sloppak", artist="AC/DC", genre="Rock")
|
||||
_put(server, "b.sloppak", artist="Band B", genre="Rock")
|
||||
_put(server, "b2.sloppak", artist="band b", genre="Rock")
|
||||
client.post("/api/artist-aliases",
|
||||
json={"raw_name": "band b", "canonical_name": "Band B"})
|
||||
similar = _page(client)["similar"]
|
||||
assert [s["artist"] for s in similar] == ["Band B"] # one entry, folded
|
||||
assert similar[0]["count"] == 2
|
||||
|
||||
|
||||
# ── /page: mb_artist_id resolution ────────────────────────────────────────────
|
||||
|
||||
def test_page_mb_artist_id_from_matched_rows(client, server):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
assert _page(client)["mb_artist_id"] == MBID
|
||||
|
||||
|
||||
def test_page_ignores_unmatched_rows_artist_id(client, server):
|
||||
"""Only matched/manual rows are identity authority — a failed row's
|
||||
leftover artist_id must not resurface."""
|
||||
_put(server, "a.sloppak")
|
||||
server.meta_db.conn.execute(
|
||||
"INSERT INTO song_enrichment (filename, match_state, mb_artist_id) "
|
||||
"VALUES ('a.sloppak', 'failed', ?)", (MBID,))
|
||||
server.meta_db.conn.commit()
|
||||
assert _page(client)["mb_artist_id"] is None
|
||||
|
||||
|
||||
# ── /links: setting gate, whitelist, scheme gate ─────────────────────────────
|
||||
|
||||
def test_links_disabled_by_default_no_network(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
r = client.get("/api/artist/AC%2FDC/links")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["links"] == {} and body.get("disabled") is True
|
||||
assert mb_artist.calls == [] # opt-in means opt-in
|
||||
|
||||
|
||||
def test_links_whitelist_mapping_and_scheme_gate(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
r = client.get("/api/artist/AC%2FDC/links")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] is True and body["cached"] is False
|
||||
links = body["links"]
|
||||
# The javascript: homepage is scheme-gated out; the first VALID one wins.
|
||||
assert links["official"] == "https://www.acdc.com"
|
||||
assert links["tour"] == "https://www.setlist.fm/setlists/acdc"
|
||||
assert links["video"] == "https://www.youtube.com/acdc"
|
||||
# Social collects; the ftp:// bandcamp is scheme-gated out.
|
||||
assert links["social"] == ["https://www.instagram.com/acdc",
|
||||
"https://soundcloud.com/acdc"]
|
||||
# Wikipedia preferred over wikidata when both exist.
|
||||
assert links["wikipedia"] == "https://en.wikipedia.org/wiki/AC/DC"
|
||||
# Nothing hostile or non-whitelisted anywhere in the payload.
|
||||
dumped = json.dumps(body)
|
||||
for bad in ("javascript:", "ftp://", "stream.example", "store.example"):
|
||||
assert bad not in dumped
|
||||
# One throttled lookup, with the url-rels include.
|
||||
assert len(mb_artist.calls) == 1
|
||||
path, params = mb_artist.calls[0]
|
||||
assert path == f"artist/{MBID}"
|
||||
assert "url-rels" in params.get("inc", "")
|
||||
|
||||
|
||||
def test_links_wikidata_fallback_when_no_wikipedia(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
mb_artist.doc = {"id": MBID, "relations": [
|
||||
{"type": "wikidata", "url": {"resource": "https://www.wikidata.org/wiki/Q27593"}},
|
||||
], "genres": []}
|
||||
links = client.get("/api/artist/AC%2FDC/links").json()["links"]
|
||||
assert links["wikipedia"] == "https://www.wikidata.org/wiki/Q27593"
|
||||
|
||||
|
||||
def test_links_cached_second_call_makes_no_network_call(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
first = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert first["cached"] is False and len(mb_artist.calls) == 1
|
||||
second = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert second["cached"] is True
|
||||
assert second["links"] == first["links"]
|
||||
assert len(mb_artist.calls) == 1 # cache hit — no re-fetch
|
||||
|
||||
|
||||
def test_links_refresh_refetches_and_updates_cache(client, server, mb_artist):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
client.get("/api/artist/AC%2FDC/links")
|
||||
mb_artist.doc = {"id": MBID, "relations": [
|
||||
{"type": "official homepage", "url": {"resource": "https://new.example"}},
|
||||
], "genres": []}
|
||||
r = client.post("/api/artist/AC%2FDC/links/refresh")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["links"]["official"] == "https://new.example"
|
||||
assert len(mb_artist.calls) == 2
|
||||
# And the refreshed value is what the next GET serves from cache.
|
||||
again = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert again["cached"] is True
|
||||
assert again["links"]["official"] == "https://new.example"
|
||||
|
||||
|
||||
# ── /links: offline / unmatched / hostile-id guards ──────────────────────────
|
||||
|
||||
def test_links_offline_returns_empty(client, server):
|
||||
"""The test env's offline default (FEEDBACK_SKIP_STARTUP_TASKS) doubles as
|
||||
the kill-switch test: matched artist + links on, but no network → empty
|
||||
links, no error, nothing cached."""
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
_enable_links(client)
|
||||
body = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert body["links"] == {} and body.get("offline") is True
|
||||
assert server.meta_db.get_artist_enrichment(MBID) is None
|
||||
|
||||
|
||||
def test_links_unmatched_artist_reports_matched_false(client, server, mb_artist):
|
||||
_put(server, "a.sloppak") # no enrichment match
|
||||
_enable_links(client)
|
||||
body = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert body == {"links": {}, "matched": False}
|
||||
assert mb_artist.calls == []
|
||||
|
||||
|
||||
def test_links_rejects_malformed_stored_mbid(client, server, mb_artist):
|
||||
"""A hand-rolled /pick body can stuff junk into mb_artist_id — the strict
|
||||
MBID shape gate must keep it off the MB request line."""
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak", artist_id="evil/../../path")
|
||||
_enable_links(client)
|
||||
body = client.get("/api/artist/AC%2FDC/links").json()
|
||||
assert body == {"links": {}, "matched": False}
|
||||
assert mb_artist.calls == []
|
||||
|
||||
|
||||
# ── demo mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_links_routes_demo_blocked_page_stays_open(client, server, monkeypatch):
|
||||
_put(server, "a.sloppak")
|
||||
_pin_match(server, "a.sloppak")
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
|
||||
assert client.get("/api/artist/AC%2FDC/links").status_code == 403
|
||||
assert client.post("/api/artist/AC%2FDC/links/refresh").status_code == 403
|
||||
# The all-local page read stays available to demo visitors.
|
||||
assert client.get("/api/artist/AC%2FDC/page").status_code == 200
|
||||
|
||||
|
||||
# ── settings keys ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_artist_page_settings_defaults_and_validation(client, server):
|
||||
cfg = client.get("/api/settings").json()
|
||||
assert cfg["artist_pages_enabled"] is True # page is local-only → ON
|
||||
assert cfg["artist_external_links"] is False # links are opt-in → OFF
|
||||
# Bool pattern: non-bool shapes return a structured error, not a 500.
|
||||
for key in ("artist_pages_enabled", "artist_external_links"):
|
||||
assert "error" in client.post("/api/settings", json={key: "yes"}).json()
|
||||
assert "error" not in client.post("/api/settings", json={key: True}).json()
|
||||
assert client.get("/api/settings").json()[key] is True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user