mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-14 08:50:03 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cb51b2ad | ||
|
|
59aa70ce5a | ||
|
|
1e9741043b | ||
|
|
77547af110 | ||
|
|
5239665e2b | ||
|
|
fd840011d2 | ||
|
|
744c848636 | ||
|
|
41cdfd576a | ||
|
|
23df6767a7 | ||
|
|
f4b59e67d6 | ||
|
|
110b47f2ae | ||
|
|
96a84996a2 | ||
|
|
2910a3c8cb | ||
|
|
115c96a3f0 | ||
|
|
48f435408f | ||
|
|
7173007412 | ||
|
|
df4e17bc99 | ||
|
|
6aed8510d7 | ||
|
|
6b0e37aa35 | ||
|
|
6bfd92aa06 | ||
|
|
bc4d2a3592 | ||
|
|
7713ca92f4 | ||
|
|
7e977b9572 | ||
|
|
095d718b85 | ||
|
|
c4bb58233d | ||
|
|
1434eb6342 | ||
|
|
24d24ef2cf | ||
|
|
58047e6ad6 | ||
|
|
817db6382b | ||
|
|
9f914770c6 | ||
|
|
5ef163e9f9 | ||
|
|
64b95d6f34 | ||
|
|
491039a12d | ||
|
|
6c42d83c31 | ||
|
|
9f0d48cb1f | ||
|
|
f9607c5c94 | ||
|
|
3e3a98a0d0 | ||
|
|
db81d7dafb | ||
|
|
a732e1f9d2 | ||
|
|
dfa825b4ab | ||
|
|
3120ae3e71 | ||
|
|
199550e5fb | ||
|
|
8fbbc761fc | ||
|
|
a791a0d8fe | ||
|
|
5ed6f454e7 | ||
|
|
331857ff2a | ||
|
|
8ca7ea4002 | ||
|
|
07ab902604 | ||
|
|
6a6efc793a | ||
|
|
6a71577e05 | ||
|
|
b29bab1884 | ||
|
|
8a2175aa1c | ||
|
|
5a0b62599d | ||
|
|
271fedda55 | ||
|
|
b0338849f8 |
@@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
|
||||
### Added
|
||||
- **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass.
|
||||
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
|
||||
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
|
||||
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
|
||||
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **A–Z rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
|
||||
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
|
||||
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the A–Z rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a "+ Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
|
||||
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
|
||||
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
|
||||
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
|
||||
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
||||
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
||||
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
|
||||
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
|
||||
@@ -40,7 +54,15 @@ 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
|
||||
- **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 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.
|
||||
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
|
||||
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) − S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
|
||||
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
|
||||
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
|
||||
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
|
||||
|
||||
@@ -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
|
||||
@@ -552,6 +564,7 @@ a local pointer + code map.
|
||||
- **Storage** — `localStorage` for all user preferences
|
||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# Host Theme Contract — design proposal
|
||||
|
||||
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
|
||||
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
|
||||
|
||||
## 1. Problem
|
||||
|
||||
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
|
||||
gradient-filled accuracy number) was built and visually verified against **only the
|
||||
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
|
||||
glow-less, near-monochrome design language — the glow ring and the colour gradient
|
||||
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
|
||||
but the **visual devices themselves did not port**, because nothing in the system says
|
||||
"this theme does / doesn't do glow rings."
|
||||
|
||||
### Root cause (three findings)
|
||||
|
||||
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
|
||||
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
|
||||
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
|
||||
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
|
||||
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
|
||||
device-named slot to express a different language. The cure is to finish that move:
|
||||
name slots by intent, with "off" (`none`) a legal value.
|
||||
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
|
||||
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
|
||||
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
|
||||
amber→near-white and washed out.
|
||||
|
||||
A process gap compounds it: **verification covered one skin**, so the regression was
|
||||
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
|
||||
ship their own independent skin systems too.
|
||||
|
||||
## 2. Current state (two disconnected systems)
|
||||
|
||||
| System | What it is | Limits |
|
||||
| --- | --- | --- |
|
||||
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults* → **devices are an ownerless gap.** |
|
||||
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
|
||||
|
||||
## 3. Goals / non-goals
|
||||
|
||||
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
|
||||
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
|
||||
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
|
||||
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
|
||||
**implement** the contract.
|
||||
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
|
||||
|
||||
## 4. The contract — three layers
|
||||
|
||||
### Layer 1 — Semantic colour **roles** (always present)
|
||||
|
||||
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
|
||||
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
|
||||
resolves — themed or not. Roles:
|
||||
|
||||
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
|
||||
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
|
||||
The existing `--fbv-*` vars stay **internal plumbing** — `theme-core.js` uses them only to
|
||||
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
|
||||
they are **not** part of this contract and plugins must not read them. (Implementation may seed
|
||||
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
|
||||
|
||||
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
|
||||
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
|
||||
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
|
||||
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
|
||||
where noted.
|
||||
|
||||
**Normative role tokens** (all `--fb-*`, all always present):
|
||||
|
||||
| Role | Token | Notes |
|
||||
| --- | --- | --- |
|
||||
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
|
||||
| text / dim | `--fb-text` `--fb-text-dim` | |
|
||||
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
|
||||
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
|
||||
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
|
||||
| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` |
|
||||
|
||||
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
|
||||
|
||||
A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed,
|
||||
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
|
||||
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
|
||||
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
|
||||
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal.
|
||||
|
||||
Two **composite recipes** carry the load:
|
||||
|
||||
- **EMPHASIS** — how this theme makes a primary action special:
|
||||
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
|
||||
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
|
||||
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
|
||||
fill/border/halo non-`none`), so a primary action is never visually flat.
|
||||
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
|
||||
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
|
||||
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
|
||||
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
|
||||
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
|
||||
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
|
||||
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
|
||||
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
|
||||
even where clip-text is unsupported.
|
||||
|
||||
> These generalize the interim per-skin tokens already shipped in `note_detect`
|
||||
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
|
||||
|
||||
### Layer 3 — JS read API + reconciliation
|
||||
|
||||
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
|
||||
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
|
||||
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
|
||||
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
|
||||
booleans. On the existing `window.feedBack` bus:
|
||||
|
||||
- `feedBack.theme.get()` → `{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
|
||||
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
|
||||
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
|
||||
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
|
||||
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
|
||||
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
|
||||
CSS-authored decorative motion.
|
||||
- `theme:changed` event → `{ id, tokens }`.
|
||||
|
||||
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
|
||||
and is valid at any time — before any theme is applied it returns the default/unthemed roles
|
||||
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
|
||||
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
|
||||
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
|
||||
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
|
||||
first theme apply.
|
||||
|
||||
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
|
||||
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
|
||||
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
|
||||
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
|
||||
never imposes a device the active theme neutralizes.
|
||||
|
||||
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
|
||||
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
|
||||
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
|
||||
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
|
||||
cross those boundaries).
|
||||
|
||||
## 5. Consumption pattern (the rule for feature authors)
|
||||
|
||||
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
|
||||
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
|
||||
> in slots; the theme owns the slots.
|
||||
|
||||
```css
|
||||
.hero-cta {
|
||||
background: var(--fb-emph-fill);
|
||||
border: var(--fb-emph-border);
|
||||
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
|
||||
color: var(--fb-emph-on); /* never hardcoded #fff again */
|
||||
border-radius: var(--fb-corner-radius);
|
||||
}
|
||||
.accuracy-number {
|
||||
/* Always-legible solid base; survives no-clip-text support too. */
|
||||
color: rgb(var(--fb-accent));
|
||||
}
|
||||
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
|
||||
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
|
||||
invisible. */
|
||||
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
|
||||
.accuracy-number {
|
||||
background: var(--fb-acc-text-fill);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
|
||||
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
|
||||
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
|
||||
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
|
||||
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
|
||||
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
|
||||
|
||||
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
|
||||
named; one central rule in the contract sheet sets it to `none` under
|
||||
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
|
||||
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
|
||||
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
|
||||
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
|
||||
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
|
||||
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
|
||||
cleanup — "one rule" describes the end state, not day one.
|
||||
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
|
||||
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
|
||||
Contrast is the theme's job, computed once — not re-judged per feature.
|
||||
|
||||
## 7. Verification gate (prevent recurrence)
|
||||
|
||||
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
|
||||
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
|
||||
key states** (rest / hover / focus / reduced-motion).
|
||||
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
|
||||
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
|
||||
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
|
||||
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
|
||||
a guard test so the matrix can't silently go stale.
|
||||
|
||||
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
|
||||
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
|
||||
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
|
||||
|
||||
## 8. Back-compat & rollout
|
||||
|
||||
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
|
||||
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
|
||||
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
|
||||
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
|
||||
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
|
||||
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
|
||||
|
||||
**Workstream (sub-tasks):**
|
||||
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
|
||||
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
|
||||
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
|
||||
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
|
||||
|
||||
## 9. Cross-apply status (already done)
|
||||
|
||||
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
|
||||
(the Layer-2 prototype) and verified across neon/esports/metal.
|
||||
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
|
||||
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
|
||||
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
|
||||
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
|
||||
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
|
||||
slots are the right seed. When/whether to graduate.
|
||||
|
||||
*(Resolved during review and folded into the sections above: the token namespace + value grammar
|
||||
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
|
||||
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
|
||||
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
|
||||
host-owned contract stylesheet outside Tailwind (§6).)*
|
||||
@@ -0,0 +1,53 @@
|
||||
# Working-tuning — on-device test checklist
|
||||
|
||||
The working-tuning series (PRs 1–9) ships with headless unit tests for every state
|
||||
machine (`tests/js/working_tuning*.test.js`, `tests/js/tuner_auto_open.test.js`). The
|
||||
items below are the parts that **cannot** be covered headlessly — they need a real mic,
|
||||
a real instrument, and (for the ASIO item) a specific audio backend. Run these on a
|
||||
build before shipping the feature to users.
|
||||
|
||||
Prereq: enable the opt-in in **Tuner → settings → "Auto-open on tuning change"** (it's
|
||||
off by default). Have a guitar (and a bass, for the per-instrument checks) on hand.
|
||||
|
||||
## 1. Auto-open + gate ("tune before you play")
|
||||
- [ ] Load a song whose tuning differs from your instrument's current tuning → the tuner
|
||||
**auto-opens** and playback **waits** (does not start underneath it).
|
||||
- [ ] Load a song already covered by your tuning → **no** auto-open, playback starts.
|
||||
- [ ] **Skip** ("I've tuned") → playback starts, and the tuner badge stops flagging this
|
||||
song's tuning (a working tuning was recorded).
|
||||
- [ ] **Back to library** / **Esc** → leaves the song, records **nothing** (re-enter the
|
||||
same song → it still prompts).
|
||||
- [ ] Take **longer than 12 s** to tune with the panel open → playback does **not** start
|
||||
underneath you (the fail-open backstop was settled once the panel opened).
|
||||
- [ ] Hit **Play** manually while the panel is open → Play wins; no double-start.
|
||||
|
||||
## 2. Both-directions retune prompt
|
||||
- [ ] From standard, load a Drop-C# song → prompted **down** (E→C#). Tune down, Skip.
|
||||
- [ ] Now load a standard song → prompted **back up** (C#→E). (Pre-series, this direction
|
||||
was silent.)
|
||||
- [ ] Switch guitar↔bass in the instrument card → each instrument remembers its **own**
|
||||
working tuning; the card label follows the selection (dim = home, amber = retuned).
|
||||
|
||||
## 3. Mic-verify (assumed → verified)
|
||||
- [ ] With a selected (non-free) tuning, tap **Verify tuning** and play each string in tune.
|
||||
Each string needs ~8 stable in-tune frames (±6 ¢); the per-string progress advances.
|
||||
- [ ] Play a string **out of tune** → it never completes; drifting out mid-streak resets it.
|
||||
- [ ] Complete all strings → the instrument card's provenance glyph flips to the **filled**
|
||||
(verified) diamond, and the recorded working tuning carries the tuning you verified
|
||||
(not a stale one).
|
||||
- [ ] Load the **next** song → the verified state **decays to assumed** (per-session only).
|
||||
- [ ] Verify against a **manually-selected** tuning (tuner opened off a song) → the stamped
|
||||
offsets match that tuning, not the last song's.
|
||||
|
||||
## 4. Mic contention with note-detection (the ASIO / exclusive-mode risk)
|
||||
This is the item flagged in the design charrette: the tuner's mic capture must not starve
|
||||
note_detect's scoring input.
|
||||
- [ ] Desktop, **ASIO / WASAPI-exclusive** device: auto-open the tuner mid-song, tune, Skip
|
||||
→ scoring resumes cleanly; no dropped input, no device-in-use error, no crash.
|
||||
- [ ] Shared/`auto` device: same flow → both the tuner and scoring read the mic without a
|
||||
stall.
|
||||
- [ ] Leave the tuner's background badge audio running + start a scored song → note_detect
|
||||
still scores (the badge auto-start doesn't hold the device exclusively).
|
||||
|
||||
Log the build hash and OS/audio backend with results; file any failure against the
|
||||
working-tuning series.
|
||||
@@ -0,0 +1,56 @@
|
||||
"""JSONC support — JSON with C-style comments.
|
||||
|
||||
Per feedpak-spec §8: when a manifest pointer resolves to a ``.jsonc`` file, a
|
||||
Reader MUST strip ``//`` line comments and ``/* */`` block comments before
|
||||
parsing the JSON content. This module implements that stripping in a single
|
||||
shared place so every sloppak/feedpak reader in this repo parses ``.jsonc``
|
||||
the same way (string-aware so comment-like text inside JSON strings survives).
|
||||
|
||||
The regex mirrors the reference implementation in ``feedpak-spec/tools/validate.py``.
|
||||
``load_json(path)`` auto-detects ``.jsonc`` by suffix; plain ``.json`` (and any
|
||||
other extension) goes straight through ``json.loads``. Use it as a drop-in
|
||||
replacement for ``json.loads(path.read_text(encoding="utf-8"))``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Match JSON string literals (preserved), // line comments, and /* block */
|
||||
# comments. A single combined alternation processed by `sub` with a callback
|
||||
# that keeps strings and replaces comments with the empty string — so
|
||||
# comment-like text inside a string literal is never stripped.
|
||||
_JSONC_STRIP_RE = re.compile(
|
||||
r'"(?:[^"\\]|\\.)*"|' # string literal — keep as-is
|
||||
r'//.*|' # // line comment — strip
|
||||
r'/\*[\s\S]*?\*/', # /* block comment */ — strip
|
||||
)
|
||||
|
||||
|
||||
def parse_jsonc(text: str) -> object:
|
||||
"""Parse a JSONC string, stripping C-style comments before JSON parsing.
|
||||
|
||||
Handles ``//`` line comments and ``/* */`` block comments, respecting
|
||||
string boundaries so that comment-like text inside strings is preserved.
|
||||
Raises ``json.JSONDecodeError`` on malformed JSON (after stripping).
|
||||
"""
|
||||
stripped = _JSONC_STRIP_RE.sub(
|
||||
lambda m: m.group(0) if m.group(0).startswith('"') else '',
|
||||
text,
|
||||
)
|
||||
return json.loads(stripped)
|
||||
|
||||
|
||||
def load_json(path: Path) -> object:
|
||||
"""Read and parse a JSON/JSONC file by path.
|
||||
|
||||
Files ending in ``.jsonc`` are stripped of comments via :func:`parse_jsonc`;
|
||||
all other files are parsed as plain JSON. UTF-8 encoded, matching every
|
||||
other reader in this repo.
|
||||
"""
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if path.name.lower().endswith(".jsonc"):
|
||||
return parse_jsonc(raw)
|
||||
return json.loads(raw)
|
||||
+7
-7
@@ -13,7 +13,6 @@ See the format spec in the project's sloppak plan for the full layout.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
@@ -37,6 +36,7 @@ SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||
|
||||
import yaml
|
||||
|
||||
from jsonc import load_json
|
||||
from safepath import safe_join
|
||||
from song import (
|
||||
Song,
|
||||
@@ -423,7 +423,7 @@ def load_song(
|
||||
if not arr_path.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(arr_path.read_text(encoding="utf-8"))
|
||||
data = load_json(arr_path)
|
||||
except Exception as e:
|
||||
log.debug("sloppak: failed to parse arrangement %r: %s", rel, e)
|
||||
continue
|
||||
@@ -489,7 +489,7 @@ def load_song(
|
||||
raw_nt = None
|
||||
if nt_path is not None and nt_path.exists():
|
||||
try:
|
||||
raw_nt = json.loads(nt_path.read_text(encoding="utf-8"))
|
||||
raw_nt = load_json(nt_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse notation %r: %s", notation_rel, e)
|
||||
if raw_nt is not None:
|
||||
@@ -528,7 +528,7 @@ def load_song(
|
||||
dt_path = None
|
||||
if dt_path is not None and dt_path.exists():
|
||||
try:
|
||||
raw = json.loads(dt_path.read_text(encoding="utf-8"))
|
||||
raw = load_json(dt_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
|
||||
raw = None
|
||||
@@ -588,7 +588,7 @@ def load_song(
|
||||
st_path = None
|
||||
if st_path is not None and st_path.exists():
|
||||
try:
|
||||
raw = json.loads(st_path.read_text(encoding="utf-8"))
|
||||
raw = load_json(st_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse song_timeline %r: %s", song_timeline_rel, e)
|
||||
raw = None
|
||||
@@ -686,7 +686,7 @@ def load_song(
|
||||
lyr_path = None
|
||||
if lyr_path is not None and lyr_path.exists():
|
||||
try:
|
||||
raw = json.loads(lyr_path.read_text(encoding="utf-8"))
|
||||
raw = load_json(lyr_path)
|
||||
except Exception as e:
|
||||
log.debug("sloppak: failed to parse lyrics %r: %s", lyrics_rel, e)
|
||||
raw = None
|
||||
@@ -773,7 +773,7 @@ def load_song(
|
||||
k_path = None
|
||||
if k_path is not None and k_path.exists():
|
||||
try:
|
||||
raw = json.loads(k_path.read_text(encoding="utf-8"))
|
||||
raw = load_json(k_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse keys %r: %s", keys_rel, e)
|
||||
raw = None
|
||||
|
||||
@@ -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.0",
|
||||
"version": "3.31.2",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+927
-96
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -37,6 +37,7 @@ def setup(app: FastAPI, context: dict):
|
||||
"showFloatingButton": True,
|
||||
"visualizationMode": "default",
|
||||
"audioInputMode": "auto",
|
||||
"autoOpenOnTuningChange": False,
|
||||
}
|
||||
if not config_file.exists():
|
||||
return defaults
|
||||
@@ -55,6 +56,11 @@ def setup(app: FastAPI, context: dict):
|
||||
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"
|
||||
# Fail closed: only a real JSON boolean enables the opt-in. A hand-edited /
|
||||
# migrated / bad-client value (e.g. the string "false" or "0") must NOT be
|
||||
# coerced to True by bool().
|
||||
_auto_open = data.get("autoOpenOnTuningChange", False)
|
||||
res["autoOpenOnTuningChange"] = _auto_open if isinstance(_auto_open, bool) else False
|
||||
|
||||
if not isinstance(res["customTunings"], dict):
|
||||
res["customTunings"] = {}
|
||||
|
||||
+442
-10
@@ -13,8 +13,17 @@
|
||||
let _lastAutoOpenSessionKey = null;
|
||||
let _autoOpenDismissedSessionKey = null;
|
||||
let _autoOpenGeneration = 0;
|
||||
// Bumped on every enable()/disable() so an in-flight open (which awaits audio start
|
||||
// with the panel already visible) can detect it was dismissed mid-open and NOT flip
|
||||
// _state.enabled on afterwards — avoiding a zombie enabled-but-hidden tuner.
|
||||
let _openGen = 0;
|
||||
let _onAutoOpenSongLoading = null;
|
||||
let _onAutoOpenSongReady = null;
|
||||
// Autoplay gate (E2): when the feature is on, hold playback on song:loading and
|
||||
// release it once we know we won't open (covered / unchanged) or the tuner is
|
||||
// dismissed — so playback waits behind a genuinely-needed retune.
|
||||
let _autoplayRelease = null;
|
||||
let _gateClaimed = false;
|
||||
|
||||
// ── Shared mutable state (read/written by screen.js; UI reads via closure) ──
|
||||
const _state = {
|
||||
@@ -133,15 +142,259 @@
|
||||
return filename + '::' + arr;
|
||||
}
|
||||
|
||||
// ── §4 instrument-coverage ────────────────────────────────────────────────
|
||||
// FeedBack is tune-to-song: the highway draws tab in the SONG's tuning, so the
|
||||
// player tunes their instrument to match. We therefore only auto-open when the
|
||||
// player's CURRENT physical tuning doesn't already cover the song — i.e. the
|
||||
// song's open-string tuning isn't an exact contiguous run inside the player's
|
||||
// strings, OR the global reference differs. So an 8-string F# player isn't
|
||||
// nagged for a 6-/7-string standard song (its top strings already match), but a
|
||||
// Drop-A song whose dropped open string the player lacks still prompts.
|
||||
function _openMidisFromFreqs(freqs) {
|
||||
const u = window._tunerUtils;
|
||||
if (!u || !Array.isArray(freqs)) return null;
|
||||
return freqs.map((f) => Math.round(u.freqToMidi(f)));
|
||||
}
|
||||
|
||||
// The song's open-string MIDI (at A440; centOffset handled separately as a
|
||||
// global). Mirrors _tuningIdentityKey's isBass / string-count derivation.
|
||||
function _songOpenMidis(songInfo) {
|
||||
const u = window._tunerUtils;
|
||||
if (!u || !songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null;
|
||||
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
|
||||
? window.feedBack.songTuningContext(songInfo)
|
||||
: { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name };
|
||||
const isBass = (typeof window.feedBack?.isBassArrangement === 'function')
|
||||
? window.feedBack.isBassArrangement(ctx)
|
||||
: (songInfo.arrangement || '').toLowerCase().includes('bass');
|
||||
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
|
||||
? window.feedBack.effectiveStringCount(songInfo.tuning, ctx)
|
||||
: (songInfo.stringCount || songInfo.tuning.length);
|
||||
if (!sc || sc <= 0) return null;
|
||||
const offsets = songInfo.tuning.slice(0, sc);
|
||||
if (!offsets.length) return null;
|
||||
return _openMidisFromFreqs(u.offsetsToFreqs(offsets, isBass));
|
||||
}
|
||||
|
||||
// The player's CURRENT physical tuning. Prefer the host-owned live working
|
||||
// tuning (window.feedBack.workingTuning) — what the instrument is *actually* in
|
||||
// right now, which advances as the player retunes — so coverage prompts fire in
|
||||
// BOTH directions (E→C# and back), not only away from a fixed profile. Feature-
|
||||
// detected: on a host without the working-tuning capability, or before it's been
|
||||
// set, we fall back to the static /api/settings instrument tuning (today's
|
||||
// behavior). Returns { midis, refCents } or null.
|
||||
// The SELECTED physical instrument's working-tuning slot identity, from /api/settings.
|
||||
// The read (_playerTuning) and the write (_publishWorkingTuning) MUST agree on this
|
||||
// key — the working tuning is a property of the player's selected instrument, not of
|
||||
// any one song — or a published tuning lands in a slot coverage never reads back.
|
||||
// Returns { isBass, sc, key }.
|
||||
function _selectedInstrument(s) {
|
||||
const isBass = !!(s && s.instrument === 'bass');
|
||||
const sc = (s && Number(s.string_count)) || (isBass ? 4 : 6);
|
||||
return { isBass, sc, key: (isBass ? 'bass' : 'guitar') + '-' + sc };
|
||||
}
|
||||
|
||||
// Memoize the player's tuning: it depends only on /api/settings + the working
|
||||
// tuning, which change on instrument:changed / working-tuning-changed (NOT per song).
|
||||
// Without this, a consumer that evaluates coverage for many items at once — the
|
||||
// library's per-song tuning-match chips — would fire one /api/settings fetch PER item
|
||||
// per paint. Cache the promise so they all share one read; invalidate on the events
|
||||
// that change the answer, AND expire after a short TTL so a settings write that
|
||||
// doesn't emit an event (e.g. the input-setup flow) still heals within seconds.
|
||||
let _playerTuningPromise = null;
|
||||
let _playerTuningAt = 0;
|
||||
const _PLAYER_TUNING_TTL_MS = 3000;
|
||||
function _playerTuning() {
|
||||
const now = (typeof Date !== 'undefined' && Date.now) ? Date.now() : 0;
|
||||
if (!_playerTuningPromise || (now - _playerTuningAt) > _PLAYER_TUNING_TTL_MS) {
|
||||
_playerTuningAt = now;
|
||||
const p = _computePlayerTuning();
|
||||
_playerTuningPromise = p;
|
||||
// Never pin a transient failure: if the read yields null (or rejects), drop
|
||||
// the cache so the next call retries. A real result stays until invalidation/TTL.
|
||||
p.then((r) => { if (r == null && _playerTuningPromise === p) _playerTuningPromise = null; },
|
||||
() => { if (_playerTuningPromise === p) _playerTuningPromise = null; });
|
||||
}
|
||||
return _playerTuningPromise;
|
||||
}
|
||||
function _invalidatePlayerTuning() { _playerTuningPromise = null; }
|
||||
|
||||
async function _computePlayerTuning() {
|
||||
const u = window._tunerUtils;
|
||||
if (!u) return null;
|
||||
// Instrument IDENTITY (which instrument is selected) + the static fallback
|
||||
// tuning, from /api/settings.
|
||||
let s = null;
|
||||
try { s = await fetch('/api/settings').then((r) => (r && r.ok ? r.json() : null)); }
|
||||
catch (_) { s = null; }
|
||||
const { isBass, sc, key } = _selectedInstrument(s);
|
||||
// Cache the resolved selection so the (synchronous) publish-on-clear writes to
|
||||
// the EXACT slot this read path uses — no second /api/settings fetch that could
|
||||
// race an instrument switch. Only cache when settings were actually read: on a
|
||||
// fetch failure we keep the last confident selection (or none → publish skips)
|
||||
// rather than recording a bogus default-instrument slot to publish into later.
|
||||
// Coverage runs before any auto-open, so this is set by the time a clear can publish.
|
||||
if (s) {
|
||||
_state._playerSelected = { isBass, sc, key, refPitch: Number(s.reference_pitch) || 440 };
|
||||
}
|
||||
// The LIVE per-instrument working tuning (advances as the player retunes) —
|
||||
// this is what makes coverage prompt in BOTH directions, not only away from a
|
||||
// fixed profile. Feature-detected; falls back to the static settings tuning
|
||||
// when unset / no host capability.
|
||||
const wt = (window.feedBack && window.feedBack.workingTuning
|
||||
&& typeof window.feedBack.workingTuning.get === 'function')
|
||||
? window.feedBack.workingTuning.get(key) : null;
|
||||
const wtHasOffsets = !!(wt && Array.isArray(wt.offsets) && wt.offsets.length);
|
||||
let freqs = null;
|
||||
if (wtHasOffsets) {
|
||||
freqs = u.offsetsToFreqs(wt.offsets.slice(0, sc), isBass);
|
||||
} else if (s && Array.isArray(s.tuning)) {
|
||||
freqs = u.offsetsToFreqs(s.tuning.slice(0, sc), isBass);
|
||||
} else if (s && typeof s.tuning === 'string') {
|
||||
const named = _state._allTunings && _state._allTunings[key];
|
||||
if (named && Array.isArray(named[s.tuning])) freqs = named[s.tuning];
|
||||
}
|
||||
if (!freqs) {
|
||||
// No confident instrument identity — settings absent, OR present but carrying
|
||||
// no instrument/string_count/tuning (a fresh profile: /api/settings omits them)
|
||||
// — and no live working tuning. We can't tell the player's tuning, so fail
|
||||
// toward prompting (null → not-covered) rather than silently assuming standard
|
||||
// and suppressing a genuinely-needed prompt.
|
||||
const hasIdentity = !!(s && (s.instrument || s.string_count || s.tuning));
|
||||
if (!hasIdentity && !wtHasOffsets) return null;
|
||||
freqs = u.offsetsToFreqs(new Array(sc).fill(0), isBass); // standard fallback
|
||||
}
|
||||
const midis = _openMidisFromFreqs(freqs);
|
||||
if (!midis) return null;
|
||||
const refPitch = (wt && Number(wt.referencePitch)) || (s && Number(s.reference_pitch)) || 440;
|
||||
return { midis, refCents: 1200 * Math.log2(refPitch / 440) };
|
||||
}
|
||||
|
||||
// PR: host workingTuning — when the player clears an AUTO-OPENED tuner we assume
|
||||
// they tuned their (selected) instrument to the song, so publish the song's tuning
|
||||
// as the live working tuning for that instrument ('assumed' — PR 4's explicit
|
||||
// "I tuned / Skip" will replace this heuristic). After the instrument->chart routing
|
||||
// PR the loaded arrangement matches the selected instrument, so the song's tuning IS
|
||||
// the player's instrument's new tuning.
|
||||
//
|
||||
// We write to the SELECTED instrument's slot (the exact key _playerTuning reads),
|
||||
// NOT a song-derived one, so the publish can't be stranded in a slot coverage never
|
||||
// looks at. If the cleared song is a chart for the OTHER instrument (a manual switch
|
||||
// to e.g. the bass part while guitar is selected), we skip — that isn't evidence the
|
||||
// selected instrument was retuned, and writing it would pollute the wrong slot.
|
||||
//
|
||||
// Synchronous, off the selection _playerTuning last resolved (`_state._playerSelected`)
|
||||
// — an auto-open always runs a coverage check first, so it's populated by the time a
|
||||
// clear can publish. Reusing it (rather than re-fetching /api/settings here) keeps the
|
||||
// write key identical to the read key and avoids racing an instrument switch.
|
||||
function _publishWorkingTuning(songInfo) {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (!wt || typeof wt.set !== 'function') return;
|
||||
if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return;
|
||||
const sel = _state._playerSelected;
|
||||
if (!sel || !sel.key || !(sel.sc > 0)) return; // coverage hasn't resolved the instrument yet
|
||||
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
|
||||
? window.feedBack.songTuningContext(songInfo)
|
||||
: { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name };
|
||||
const songIsBass = (typeof window.feedBack?.isBassArrangement === 'function')
|
||||
? window.feedBack.isBassArrangement(ctx)
|
||||
: (songInfo.arrangement || '').toLowerCase().includes('bass');
|
||||
if (songIsBass !== sel.isBass) return; // cross-instrument chart — don't pollute the selected slot
|
||||
wt.set({
|
||||
offsets: songInfo.tuning.slice(0, sel.sc),
|
||||
stringCount: sel.sc,
|
||||
instrument: sel.isBass ? 'bass' : 'guitar',
|
||||
referencePitch: sel.refPitch,
|
||||
source: 'tuner',
|
||||
}, { instrument: sel.key, provenance: 'assumed' });
|
||||
}
|
||||
|
||||
// The retune the player would need to match this song, as a structured report:
|
||||
// { covered, retune: [{ from, to }], reference, cantCover }
|
||||
// — covered: the physical tuning already matches (the song's open strings are an
|
||||
// exact contiguous run inside the player's strings) → no retune;
|
||||
// — retune: the per-string note changes (e.g. { from:'B', to:'A' }) of the best
|
||||
// contiguous alignment — what the badge cue names;
|
||||
// — reference: a whole-instrument A4/centOffset mismatch (A440 vs A432, octave);
|
||||
// — cantCover: the song needs more strings than the instrument has.
|
||||
// Conservative: any missing data → { covered:false } so a needed prompt/cue is
|
||||
// never silently dropped on a fetch hiccup.
|
||||
async function _computeCoverageReport(songInfo) {
|
||||
const u = window._tunerUtils;
|
||||
const none = { covered: false, retune: [], reference: false, cantCover: false };
|
||||
if (!u) return none;
|
||||
const song = _songOpenMidis(songInfo);
|
||||
if (!song || !song.length) return none;
|
||||
const player = await _playerTuning();
|
||||
if (!player || !player.midis.length) return none;
|
||||
const reference = Math.abs((Number(songInfo?.centOffset) || 0) - player.refCents) > 25;
|
||||
if (player.midis.length < song.length) return { covered: false, retune: [], reference, cantCover: true };
|
||||
// Best contiguous alignment = the run with the fewest per-string mismatches
|
||||
// (extended-range adds strings at the ends — match by pitch, not index).
|
||||
let best = null;
|
||||
for (let start = 0; start + song.length <= player.midis.length; start++) {
|
||||
const diffs = [];
|
||||
for (let i = 0; i < song.length; i++) {
|
||||
const pm = player.midis[start + i];
|
||||
if (pm !== song[i]) diffs.push({ from: u.midiToNote(pm, false), to: u.midiToNote(song[i], false) });
|
||||
}
|
||||
if (!best || diffs.length < best.length) best = diffs;
|
||||
if (!diffs.length) break;
|
||||
}
|
||||
const covered = !reference && best.length === 0;
|
||||
return { covered, retune: covered ? [] : best, reference, cantCover: false };
|
||||
}
|
||||
|
||||
// Dedup the coverage computation (which fetches /api/settings): the auto-open gate
|
||||
// AND the badge cue both call this on the same song:ready. Cache the in-flight/last
|
||||
// result per song so they share ONE fetch. Invalidated when anything that changes the
|
||||
// answer happens — a new song (song:loading), an instrument switch (instrument:changed),
|
||||
// or a retune (working-tuning-changed) — so the cache can never go stale within a song.
|
||||
let _coverageCache = null; // { key, promise }
|
||||
function _coverageReport(songInfo) {
|
||||
const key = _autoOpenSessionKey(songInfo) + '|'
|
||||
+ (songInfo && Array.isArray(songInfo.tuning) ? songInfo.tuning.join(',') : '')
|
||||
+ '|' + (songInfo && songInfo.centOffset != null ? songInfo.centOffset : ''); // coverage uses centOffset
|
||||
if (_coverageCache && _coverageCache.key === key) return _coverageCache.promise;
|
||||
const promise = _computeCoverageReport(songInfo);
|
||||
_coverageCache = { key, promise };
|
||||
return promise;
|
||||
}
|
||||
function _invalidateCoverageCache() { _coverageCache = null; }
|
||||
|
||||
// Boolean form used to gate the auto-open prompt.
|
||||
async function _coveredByPlayerInstrument(songInfo) {
|
||||
return (await _coverageReport(songInfo)).covered;
|
||||
}
|
||||
|
||||
function _releaseGate() {
|
||||
if (_autoplayRelease) { try { _autoplayRelease(); } catch (_) { /* */ } _autoplayRelease = null; }
|
||||
}
|
||||
|
||||
function _onAutoOpenSongLoadingHandler() {
|
||||
_autoOpenGeneration++;
|
||||
_autoOpenDismissedSessionKey = null;
|
||||
_lastAutoOpenSessionKey = null;
|
||||
_invalidateCoverageCache();
|
||||
// Claim the autoplay gate NOW (synchronously, before song:ready) when the
|
||||
// feature is on, so playback can wait behind a needed retune. Released on
|
||||
// song:ready if we don't open, or when the tuner is dismissed.
|
||||
_releaseGate();
|
||||
_gateClaimed = false;
|
||||
_autoplayRelease = (_state._serverConfig && _state._serverConfig.autoOpenOnTuningChange
|
||||
&& window.feedBack && typeof window.feedBack.holdAutoplay === 'function')
|
||||
? window.feedBack.holdAutoplay() : null;
|
||||
}
|
||||
|
||||
async function _maybeAutoOpenOnTuningChange() {
|
||||
if (!document.getElementById('player')?.classList.contains('active')) return;
|
||||
|
||||
// Opt-in (default off): only auto-open when the user enabled it in the
|
||||
// tuner settings. Ensure config is loaded so the first song:ready after
|
||||
// boot still reads the real flag; fail closed if it can't load.
|
||||
if (!_state._serverConfig) { try { await loadConfig(); } catch (_) { /* */ } }
|
||||
if (!_state._serverConfig || !_state._serverConfig.autoOpenOnTuningChange) return;
|
||||
|
||||
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
|
||||
if (!songInfo) return;
|
||||
|
||||
@@ -165,10 +418,21 @@
|
||||
if (_lastAutoOpenSessionKey === sessionKey) return;
|
||||
if (!window.tuner || typeof window.tuner.enable !== 'function') return;
|
||||
|
||||
// §4: skip the prompt when the player's physical instrument already covers
|
||||
// this song's tuning (e.g. an 8-string F# playing a 6-/7-string standard
|
||||
// song). Async (fetches /api/settings) — re-check the generation after.
|
||||
const covered = await _coveredByPlayerInstrument(songInfo);
|
||||
if (myGen !== _autoOpenGeneration) return;
|
||||
if (covered) return;
|
||||
|
||||
_lastAutoOpenSessionKey = sessionKey;
|
||||
try {
|
||||
await window.tuner.enable();
|
||||
await window.tuner.enable({ auto: true });
|
||||
if (myGen !== _autoOpenGeneration) return;
|
||||
_gateClaimed = true; // tuner is open → keep the autoplay gate until it's dismissed
|
||||
// The hold is now intentional and user-dismissable — cancel the fail-open
|
||||
// backstop so it can't start playback while the player is still tuning.
|
||||
if (_autoplayRelease && typeof _autoplayRelease.settle === 'function') _autoplayRelease.settle();
|
||||
} catch (e) {
|
||||
console.warn('Tuner: auto-open failed:', e && e.message ? e.message : e);
|
||||
if (_lastAutoOpenSessionKey === sessionKey) _lastAutoOpenSessionKey = null;
|
||||
@@ -183,9 +447,26 @@
|
||||
function _installAutoOpenListeners() {
|
||||
if (_onAutoOpenSongLoading || !window.feedBack?.on) return;
|
||||
_onAutoOpenSongLoading = _onAutoOpenSongLoadingHandler;
|
||||
_onAutoOpenSongReady = () => { _maybeAutoOpenOnTuningChange(); };
|
||||
_onAutoOpenSongReady = async () => {
|
||||
const myGen = _autoOpenGeneration;
|
||||
await _maybeAutoOpenOnTuningChange();
|
||||
// A newer song:loading may have superseded us while awaiting — it owns the
|
||||
// gate/_gateClaimed now, so don't release its hold based on our stale view.
|
||||
if (myGen !== _autoOpenGeneration) return;
|
||||
if (!_gateClaimed) _releaseGate(); // not gating this song → let it play
|
||||
};
|
||||
window.feedBack.on('song:loading', _onAutoOpenSongLoading);
|
||||
window.feedBack.on('song:ready', _onAutoOpenSongReady);
|
||||
// The badge (static/v3/badges.js) emits this on the feedBack bus when the player
|
||||
// switches instrument. Drop the cached selection so a publish-on-clear can't write
|
||||
// to the previously-selected instrument's slot; the next coverage read re-resolves
|
||||
// it. Until then _publishWorkingTuning skips (safe — no mis-slotted write).
|
||||
window.feedBack.on('instrument:changed', () => {
|
||||
_state._playerSelected = null; _invalidatePlayerTuning(); _invalidateCoverageCache();
|
||||
});
|
||||
// A retune (working tuning published on a tuner clear) changes coverage for the
|
||||
// current song — drop the cached player tuning + report so a re-evaluation recomputes.
|
||||
window.feedBack.on('working-tuning-changed', () => { _invalidatePlayerTuning(); _invalidateCoverageCache(); });
|
||||
}
|
||||
|
||||
// ── Player sync helpers ───────────────────────────────────────────
|
||||
@@ -209,6 +490,12 @@
|
||||
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
|
||||
? window.feedBack.effectiveStringCount(songInfo.tuning, ctx)
|
||||
: (songInfo.stringCount || songInfo.tuning.length);
|
||||
// A tuning change invalidates an in-flight mic-verify — its captured targets
|
||||
// and offsets are now stale, so it must not complete against the old tuning.
|
||||
if (_verify && String(songInfo.tuning.slice(0, sc)) !== String(_verify.offsets)) {
|
||||
verifyCancel();
|
||||
_tunerUIApi?.resetVerify?.();
|
||||
}
|
||||
_state.currentSongOffsets = songInfo.tuning.slice(0, sc);
|
||||
_state.currentSongIsBass = isBass;
|
||||
_state.currentSongStringCount = sc;
|
||||
@@ -341,8 +628,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
async function enable(opts) {
|
||||
if (_state.enabled) return;
|
||||
const myOpen = ++_openGen; // this open's token; a disable()/newer open invalidates it
|
||||
// An AUTO-open (the "this song needs a different tuning" nudge) must
|
||||
// PERSIST: it is NOT dismissed by the autoplay song:play that follows
|
||||
// song entry, a stray click, or a same-screen re-emit — only by the
|
||||
// Skip/× buttons or leaving the song. A manual open keeps the classic
|
||||
// click-away / play-to-close behaviour.
|
||||
const auto = !!(opts && opts.auto);
|
||||
_state.autoOpened = auto;
|
||||
await _loadScript('/api/plugins/tuner/utils/tuning-utils.js');
|
||||
await _loadScript('/api/plugins/tuner/utils/audio.js');
|
||||
await _loadScript('/api/plugins/tuner/utils/ui.js');
|
||||
@@ -371,15 +666,33 @@
|
||||
_state.uiContainer.classList.add('flex');
|
||||
_tunerUIApi.positionPanel();
|
||||
_tunerUIApi.updateFreeTuneUI();
|
||||
// "Skip" is the auto-open nudge's explicit dismiss; hidden for a manual
|
||||
// open (the × / click-away already close those).
|
||||
if (_state.skipBtn) _state.skipBtn.classList.toggle('hidden', !auto);
|
||||
// Auto-open shows the "Back to library" escape hatch and hides the ×:
|
||||
// the Skip / Back buttons + Esc are the auto-open's dismiss surface, so a
|
||||
// gated retune always offers a way forward AND a way out.
|
||||
if (_state.backBtn) _state.backBtn.classList.toggle('hidden', !auto);
|
||||
if (_state.closeBtn) _state.closeBtn.classList.toggle('hidden', !!auto);
|
||||
|
||||
// Close when clicking outside the panel. Deferred so the badge's
|
||||
// opening click doesn't bubble up to the document and fire immediately.
|
||||
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
|
||||
_outsideClickClose = () => { if (_state.enabled) disable(); };
|
||||
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
|
||||
// Close when clicking outside the panel. Deferred so the badge's opening
|
||||
// click doesn't bubble up to the document and fire immediately. Skipped
|
||||
// for an auto-open: the user never clicked to open it, so their first
|
||||
// unrelated click must not dismiss it (it persists until Skip / Back to
|
||||
// library / Esc).
|
||||
if (!auto) {
|
||||
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
|
||||
_outsideClickClose = () => { if (_state.enabled) disable(); };
|
||||
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
|
||||
}
|
||||
|
||||
if (window.feedBack && !_onScreenChanged) {
|
||||
_onScreenChanged = () => { disable(); };
|
||||
// Auto-opened: close only when we actually LEAVE the song — a player
|
||||
// re-emit while staying put must not tear down the nudge. Manual:
|
||||
// unchanged (any screen change closes it).
|
||||
_onScreenChanged = () => {
|
||||
if (!_state.autoOpened || !document.getElementById('player')?.classList.contains('active')) disable();
|
||||
};
|
||||
_onSongReady = () => {
|
||||
_tunerUIApi.renderTuningOptions();
|
||||
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
|
||||
@@ -396,6 +709,10 @@
|
||||
{ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode },
|
||||
_tunerUIApi.updateUI
|
||||
);
|
||||
// The panel is visible (with ×/Skip) across the audio-start await above, so a
|
||||
// dismiss can land here. If so, disable() already tore the panel down and
|
||||
// bumped _openGen — do NOT flip enabled on (that would leave enabled-but-hidden).
|
||||
if (myOpen !== _openGen) return;
|
||||
_state.enabled = true;
|
||||
if (window.tuner?.updateButtons) window.tuner.updateButtons();
|
||||
} catch (e) {
|
||||
@@ -406,10 +723,16 @@
|
||||
}
|
||||
|
||||
function disable() {
|
||||
_openGen++; // invalidate any in-flight enable() so it won't re-enable after this teardown
|
||||
const wasEnabled = _state.enabled;
|
||||
const wasAutoOpened = _state.autoOpened;
|
||||
const onPlayer = document.getElementById('player')?.classList.contains('active');
|
||||
_state.enabled = false;
|
||||
_state.autoOpened = false;
|
||||
_releaseGate(); // dismissing a gated auto-open releases playback (it starts now)
|
||||
_state.manualTargetFreq = null;
|
||||
verifyCancel(); // a running mic-verify ends when the panel closes
|
||||
_tunerUIApi?.resetVerify?.();
|
||||
if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; }
|
||||
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
|
||||
if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); }
|
||||
@@ -427,8 +750,17 @@
|
||||
}
|
||||
if (wasEnabled && onPlayer) {
|
||||
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
|
||||
if (songInfo) _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
|
||||
if (songInfo) {
|
||||
_autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
|
||||
// Clearing an auto-opened tuner = the player tuned to this song:
|
||||
// publish the song's tuning as their instrument's live working tuning
|
||||
// so coverage stops nagging for it (and prompts on the way back). But
|
||||
// if a mic-verify already wrote 'verified' this session, a plain
|
||||
// 'assumed' publish would immediately clobber it — leave it verified.
|
||||
if (wasAutoOpened && !_verifiedPublished) _publishWorkingTuning(songInfo);
|
||||
}
|
||||
}
|
||||
_verifiedPublished = false; // consumed — fresh for the next tuner session
|
||||
}
|
||||
|
||||
window.tuner = {
|
||||
@@ -472,10 +804,110 @@
|
||||
_installAutoOpenListeners();
|
||||
}).catch(e => console.error(e));
|
||||
_installAutoOpenListeners();
|
||||
|
||||
// ── Mic-verify (working-tuning PR 9b) ──────────────────────────────────────
|
||||
// A choreographed per-string check that promotes the current working tuning
|
||||
// from 'assumed' to 'verified' — the ONLY thing that may ever claim 'verified'
|
||||
// (audio-engine's honesty rule). The player plays each string; once every one
|
||||
// reads in-tune (±VERIFY_TOL_CENTS) and holds for VERIFY_STABLE frames we stamp
|
||||
// provenance:'verified' + verifiedStrings. Cancels on tuner close / tuning change.
|
||||
const VERIFY_TOL_CENTS = 6;
|
||||
const VERIFY_STABLE = 8;
|
||||
let _verify = null;
|
||||
let _verifiedPublished = false; // set when a mic-verify wrote 'verified' this session
|
||||
|
||||
function verifyState() {
|
||||
if (!_verify) return null;
|
||||
return {
|
||||
complete: _verify.complete,
|
||||
done: _verify.targets.map((t) => t.done),
|
||||
remaining: _verify.targets.filter((t) => !t.done).length,
|
||||
};
|
||||
}
|
||||
// Start a verify session for `targets` (freqs; defaults to the selected tuning).
|
||||
// Captures the tuning's OFFSETS now (explicit arg, else the current song's) so that
|
||||
// when it completes we stamp 'verified' onto the exact tuning that was confirmed.
|
||||
// Per-string semitone offsets (from standard) of the tuning we're verifying, derived
|
||||
// from its target freqs. This is authoritative for BOTH the song ('_current') tuning
|
||||
// and a manually-selected tuning — unlike _state.currentSongOffsets, which for a manual
|
||||
// tuning is a stale/different song's tuning that must NOT be stamped 'verified'. The
|
||||
// player's reference pitch scales both the targets and the standard, so it cancels.
|
||||
function _tuningOffsetsFromFreqs(freqs) {
|
||||
const u = window._tunerUtils;
|
||||
if (!u || typeof u.offsetsToFreqs !== 'function' || typeof u.freqToMidi !== 'function') return null;
|
||||
const isBass = /^bass/.test(_state.selectedInstrument || ''); // the selected instrument, not the song's
|
||||
const refScale = (Number(_state.referencePitch) || 440) / 440;
|
||||
const std = u.offsetsToFreqs(new Array(freqs.length).fill(0), isBass);
|
||||
if (!Array.isArray(std) || std.length !== freqs.length) return null;
|
||||
const out = [];
|
||||
for (let i = 0; i < freqs.length; i++) {
|
||||
const f = Number(freqs[i]);
|
||||
const s = Number(std[i]) * refScale;
|
||||
if (!(f > 0) || !(s > 0)) return null;
|
||||
out.push(Math.round(u.freqToMidi(f) - u.freqToMidi(s)) || 0); // normalize -0 → 0
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function verifyStart(targets, offsets) {
|
||||
const freqs = (Array.isArray(targets) && targets.length) ? targets : _state.selectedTuning;
|
||||
if (!Array.isArray(freqs) || !freqs.length) return null;
|
||||
// Explicit offsets win (callers/tests that already have them); otherwise derive
|
||||
// them from the tuning actually being verified.
|
||||
const offs = (Array.isArray(offsets) && offsets.length) ? offsets.slice() : _tuningOffsetsFromFreqs(freqs);
|
||||
_verify = { targets: freqs.map((f) => ({ freq: f, streak: 0, done: false })), complete: false, offsets: offs };
|
||||
_verifiedPublished = false;
|
||||
return verifyState();
|
||||
}
|
||||
function verifyCancel() { _verify = null; }
|
||||
// Feed one processed frame (its matched target freq + cents-off). Requires
|
||||
// CONSECUTIVE in-tune frames per string: the one confirmed string advances, and
|
||||
// every other not-yet-done string's streak resets — so a run can't accumulate across
|
||||
// silence / wrong-string / out-of-tune frames. Completes + stamps 'verified' when all pass.
|
||||
function verifyFeed(targetFreq, cents) {
|
||||
if (!_verify || _verify.complete) return verifyState();
|
||||
let hit = null;
|
||||
if (targetFreq != null) {
|
||||
const t = _verify.targets.find((x) => Math.abs(x.freq - targetFreq) < 0.5);
|
||||
if (t && !t.done && isFinite(cents) && Math.abs(cents) <= VERIFY_TOL_CENTS) hit = t;
|
||||
}
|
||||
for (const t of _verify.targets) {
|
||||
if (t.done) continue;
|
||||
if (t === hit) { if (++t.streak >= VERIFY_STABLE) t.done = true; }
|
||||
else t.streak = 0;
|
||||
}
|
||||
if (_verify.targets.every((x) => x.done)) {
|
||||
_verify.complete = true;
|
||||
_publishVerified();
|
||||
}
|
||||
return verifyState();
|
||||
}
|
||||
// Promote the working tuning to 'verified'. Write the CONFIRMED tuning's offsets
|
||||
// atomically with provenance + verifiedStrings (into the selected instrument's slot),
|
||||
// so 'verified' can never attach to stale offsets the slot happened to hold.
|
||||
function _publishVerified() {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (!wt || typeof wt.set !== 'function') return;
|
||||
const offsets = (_verify && Array.isArray(_verify.offsets)) ? _verify.offsets.slice() : null;
|
||||
if (!offsets || !offsets.length) return; // nothing concrete to claim verified
|
||||
const sel = _state._playerSelected;
|
||||
const next = { offsets: offsets, stringCount: offsets.length, verifiedStrings: offsets.map(() => true) };
|
||||
if (sel && sel.key) { next.instrument = sel.isBass ? 'bass' : 'guitar'; next.referencePitch = sel.refPitch; }
|
||||
const opts = (sel && sel.key) ? { instrument: sel.key, provenance: 'verified' } : { provenance: 'verified' };
|
||||
try { wt.set(next, opts); _verifiedPublished = true; } catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
window._tunerAutoOpen = {
|
||||
tuningIdentityKey: _tuningIdentityKey,
|
||||
sessionKey: _autoOpenSessionKey,
|
||||
maybeAutoOpenOnTuningChange: _maybeAutoOpenOnTuningChange,
|
||||
coveredByPlayerInstrument: _coveredByPlayerInstrument,
|
||||
coverageReport: _coverageReport,
|
||||
playerTuning: _playerTuning,
|
||||
publishWorkingTuning: _publishWorkingTuning,
|
||||
verifyStart: verifyStart,
|
||||
verifyFeed: verifyFeed,
|
||||
verifyCancel: verifyCancel,
|
||||
verifyState: verifyState,
|
||||
onSongLoading: _onAutoOpenSongLoadingHandler,
|
||||
getState() {
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
</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>
|
||||
<p class="text-[11px] text-gray-500">When a song (or arrangement) needs a different tuning, pop the tuner open automatically. It stays open until you Skip or close it.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="tuner-auto-open" class="sr-only peer" onchange="window._tunerToggleAutoOpen(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>
|
||||
|
||||
<script>
|
||||
if (window.feedBackDesktop && window.feedBackDesktop.isDesktop) {
|
||||
document.currentScript.insertAdjacentHTML('beforebegin', `
|
||||
@@ -107,6 +118,9 @@
|
||||
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
|
||||
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
|
||||
|
||||
const autoOpenToggle = document.getElementById('tuner-auto-open');
|
||||
if (autoOpenToggle) autoOpenToggle.checked = config.autoOpenOnTuningChange === true;
|
||||
|
||||
render();
|
||||
} catch (e) { console.error('Tuner settings: load failed', e); }
|
||||
}
|
||||
@@ -121,6 +135,11 @@
|
||||
save();
|
||||
};
|
||||
|
||||
window._tunerToggleAutoOpen = (enabled) => {
|
||||
config.autoOpenOnTuningChange = enabled;
|
||||
save();
|
||||
};
|
||||
|
||||
async function save(opts) {
|
||||
try {
|
||||
await fetch('/api/plugins/tuner/config', {
|
||||
|
||||
+107
-1
@@ -305,6 +305,12 @@ window._tunerUI = function(state, actions) {
|
||||
function renderStringNotes() {
|
||||
if (!state.stringNoteContainer) return;
|
||||
state.stringNoteContainer.innerHTML = '';
|
||||
// Show the mic-verify control only for a selected (non-free) tuning.
|
||||
if (state.verifyRow) {
|
||||
const hasTuning = !!(state.selectedTuning && state.selectedTuning.length && !state.freeTune);
|
||||
state.verifyRow.classList.toggle('hidden', !hasTuning);
|
||||
if (!hasTuning) resetVerifyUI();
|
||||
}
|
||||
if (!state.selectedTuning || state.selectedTuning.length === 0) {
|
||||
_syncStringOrderHelp(0);
|
||||
return;
|
||||
@@ -327,6 +333,41 @@ window._tunerUI = function(state, actions) {
|
||||
_syncStringOrderHelp(total);
|
||||
}
|
||||
|
||||
// ── Mic-verify UI (working-tuning PR 9b) ───────────────────────────────────
|
||||
function _markVerifiedStrings(done) {
|
||||
if (!state.stringNoteContainer) return;
|
||||
state.stringNoteContainer.querySelectorAll('[data-freq]').forEach((btn, i) => {
|
||||
btn.classList.toggle('ring-2', !!done[i]);
|
||||
btn.classList.toggle('ring-emerald-400', !!done[i]);
|
||||
});
|
||||
}
|
||||
function _syncVerifyProgress(vs) {
|
||||
if (!vs || !state.verifyStatus) return;
|
||||
const total = vs.done.length;
|
||||
const done = vs.done.filter(Boolean).length;
|
||||
state.verifyStatus.classList.remove('hidden');
|
||||
state.verifyStatus.textContent = vs.complete
|
||||
? '✓ In tune — tuning verified'
|
||||
: (done + ' of ' + total + ' strings in tune');
|
||||
_markVerifiedStrings(vs.done);
|
||||
if (vs.complete && state.verifyBtn) state.verifyBtn.textContent = 'Verify tuning';
|
||||
}
|
||||
function _startVerify() {
|
||||
if (!window._tunerAutoOpen || typeof window._tunerAutoOpen.verifyStart !== 'function') return;
|
||||
const vs = window._tunerAutoOpen.verifyStart();
|
||||
if (!vs) return;
|
||||
if (state.verifyBtn) state.verifyBtn.textContent = 'Verifying — play each string…';
|
||||
_syncVerifyProgress(vs);
|
||||
}
|
||||
function resetVerifyUI() {
|
||||
if (window._tunerAutoOpen && typeof window._tunerAutoOpen.verifyCancel === 'function') {
|
||||
window._tunerAutoOpen.verifyCancel();
|
||||
}
|
||||
if (state.verifyBtn) state.verifyBtn.textContent = 'Verify tuning';
|
||||
if (state.verifyStatus) { state.verifyStatus.classList.add('hidden'); state.verifyStatus.textContent = ''; }
|
||||
_markVerifiedStrings([]);
|
||||
}
|
||||
|
||||
function updateUI(result) {
|
||||
const { smoothedFreq, rms, hasSignal } = result;
|
||||
const vizMode = state.manualTargetFreq ? 'manual'
|
||||
@@ -387,6 +428,13 @@ window._tunerUI = function(state, actions) {
|
||||
if (window.feedBack && window.feedBack.emit) {
|
||||
window.feedBack.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true });
|
||||
}
|
||||
// Mic-verify: feed the matched string + cents to a running verify session
|
||||
// and reflect per-string progress on the panel.
|
||||
if (!isManual && !state.freeTune && window._tunerAutoOpen
|
||||
&& typeof window._tunerAutoOpen.verifyFeed === 'function') {
|
||||
const vs = window._tunerAutoOpen.verifyFeed(targetFreq, Math.round(cents));
|
||||
if (vs) _syncVerifyProgress(vs);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFloatingButtonVisibility() {
|
||||
@@ -565,6 +613,17 @@ window._tunerUI = function(state, actions) {
|
||||
title.textContent = 'TUNER';
|
||||
header.appendChild(title);
|
||||
|
||||
// Explicit close (the panel had no in-box dismiss before; persist mode
|
||||
// needs one). Mirrors the settings gear on the opposite side.
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'absolute left-0 text-fb-textDim hover:text-fb-text transition-colors text-lg leading-none';
|
||||
closeBtn.setAttribute('aria-label', 'Close tuner');
|
||||
closeBtn.title = 'Close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.onclick = () => actions.disable();
|
||||
state.closeBtn = closeBtn;
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.className = 'absolute right-0 text-fb-textDim hover:text-fb-text transition-colors';
|
||||
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`;
|
||||
@@ -624,6 +683,48 @@ window._tunerUI = function(state, actions) {
|
||||
state.vizContainer.className = 'w-full';
|
||||
state.uiContainer.appendChild(state.vizContainer);
|
||||
|
||||
// Mic-verify control (working-tuning PR 9b): play each string in tune to
|
||||
// confirm your tuning — promotes 'assumed' → 'verified'. Shown only for a
|
||||
// selected (non-free) tuning; visibility managed in renderStringNotes().
|
||||
state.verifyRow = document.createElement('div');
|
||||
state.verifyRow.className = 'w-full mt-2 hidden';
|
||||
state.verifyBtn = document.createElement('button');
|
||||
state.verifyBtn.className = 'tuner-verify-btn w-full text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
|
||||
state.verifyBtn.textContent = 'Verify tuning';
|
||||
state.verifyBtn.title = 'Play each string in tune to confirm — marks your tuning verified';
|
||||
state.verifyBtn.onclick = () => _startVerify();
|
||||
state.verifyRow.appendChild(state.verifyBtn);
|
||||
state.verifyStatus = document.createElement('div');
|
||||
state.verifyStatus.className = 'text-[10px] text-fb-textDim text-center mt-1 hidden';
|
||||
state.verifyRow.appendChild(state.verifyStatus);
|
||||
state.uiContainer.appendChild(state.verifyRow);
|
||||
|
||||
// Auto-open nudge's explicit dismiss (hidden unless auto-opened; enable()
|
||||
// toggles it). Closes the same way as the × — disable().
|
||||
const skipBtn = document.createElement('button');
|
||||
skipBtn.className = 'tuner-skip-btn hidden w-full mt-3 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
|
||||
skipBtn.textContent = 'Skip';
|
||||
skipBtn.title = "I've tuned — play the song";
|
||||
skipBtn.onclick = () => actions.disable();
|
||||
state.skipBtn = skipBtn;
|
||||
state.uiContainer.appendChild(skipBtn);
|
||||
|
||||
// Auto-open escape hatch: leave the song entirely instead of committing to
|
||||
// the play-now choice — so a gated retune is never a one-way trap. Mirrors
|
||||
// Escape (the player's "Back to library" shortcut) and, like Escape, does
|
||||
// NOT record a tuning (you're leaving, not asserting you tuned).
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'tuner-back-btn hidden w-full mt-2 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
|
||||
backBtn.textContent = 'Back to library';
|
||||
backBtn.title = 'Leave the song (Esc)';
|
||||
backBtn.onclick = () => {
|
||||
const exit = window.feedBack && window.feedBack.requestExitSong;
|
||||
if (typeof exit === 'function') exit();
|
||||
else if (typeof window.requestExitSong === 'function') window.requestExitSong();
|
||||
};
|
||||
state.backBtn = backBtn;
|
||||
state.uiContainer.appendChild(backBtn);
|
||||
|
||||
document.body.appendChild(state.uiContainer);
|
||||
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
|
||||
}
|
||||
@@ -683,7 +784,11 @@ window._tunerUI = function(state, actions) {
|
||||
|
||||
const handlePlay = () => {
|
||||
updateFloatingButtonVisibility();
|
||||
if (state.enabled) actions.disable();
|
||||
// A manually-opened tuner closes when playback starts (you don't tune
|
||||
// while playing). An AUTO-opened tuner PERSISTS through the autoplay
|
||||
// song:play that immediately follows song entry — that auto-close was
|
||||
// the "opens then vanishes ~1s later" flash. It closes via Skip/×/leave.
|
||||
if (state.enabled && !state.autoOpened) actions.disable();
|
||||
};
|
||||
const handleStop = () => updateFloatingButtonVisibility();
|
||||
|
||||
@@ -737,6 +842,7 @@ window._tunerUI = function(state, actions) {
|
||||
renderTuningOptions,
|
||||
renderStringNotes,
|
||||
updateUI,
|
||||
resetVerify: resetVerifyUI,
|
||||
updateInstrumentDisplay: _updateInstrumentDisplay,
|
||||
updateSaveAsCustomVisibility: _updateSaveAsCustomVisibility,
|
||||
updateFreeTuneUI,
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
var _TUNER_LABEL_H = 12; // px height of each drum label
|
||||
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
|
||||
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
|
||||
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
|
||||
var _TUNER_IN_TUNE_THRESHOLD = 2;
|
||||
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
|
||||
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
|
||||
@@ -321,9 +323,34 @@
|
||||
currentAngle += (targetAngle - currentAngle) * lf;
|
||||
_setNeedle(currentAngle);
|
||||
|
||||
// Stop once the needle has settled on its target — a static needle
|
||||
// needs no repaint. update() re-kicks the loop when a new reading
|
||||
// moves the target, so this idles the always-on tuner (no signal /
|
||||
// steady pitch) instead of pinning a core at 60 fps forever.
|
||||
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
|
||||
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
|
||||
currentDrumY = targetDrumY; currentAngle = targetAngle;
|
||||
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
||||
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
|
||||
_setNeedle(currentAngle);
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
// Restart the loop only when there's actually something to animate toward
|
||||
// (a new target). Reset lastTime so the first frame after an idle gap
|
||||
// doesn't take one big easing step.
|
||||
function _kick() {
|
||||
if (rafId === null
|
||||
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|
||||
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
|
||||
lastTime = performance.now();
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
@@ -360,6 +387,7 @@
|
||||
bulbEl.style.backgroundColor = '#2a1010';
|
||||
bulbEl.style.border = '2px solid #4a2020';
|
||||
bulbEl.style.boxShadow = 'none';
|
||||
_kick(); // animate back to rest, then the loop self-stops
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -376,6 +404,7 @@
|
||||
bulbEl.style.border = '2px solid #4a2020';
|
||||
bulbEl.style.boxShadow = 'none';
|
||||
}
|
||||
_kick(); // a new reading moved the target → run until it settles
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -620,8 +620,20 @@
|
||||
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
|
||||
|
||||
_applyTickStates();
|
||||
|
||||
// No signal and both the glow and strobe drift have fully settled →
|
||||
// idle the loop. update() re-kicks it on the next note.
|
||||
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
|
||||
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
|
||||
_mt3RafId = null;
|
||||
_mt3LastTime = null;
|
||||
return;
|
||||
}
|
||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||
}
|
||||
function _kick() {
|
||||
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
|
||||
}
|
||||
_mt3RafId = requestAnimationFrame(_animateStrobe);
|
||||
|
||||
// ── MODE button ───────────────────────────────────────────────
|
||||
@@ -677,6 +689,7 @@
|
||||
_renderNote(' ');
|
||||
_applyAccidental();
|
||||
}
|
||||
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
|
||||
}
|
||||
|
||||
// ── Public: destroy ───────────────────────────────────────────
|
||||
|
||||
@@ -354,10 +354,19 @@
|
||||
if (_smoothedCents > 0) { speed = -speed; }
|
||||
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
|
||||
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
|
||||
} else if (_currentCents === 0) {
|
||||
// Fully decelerated and no live signal → idle the loop instead of
|
||||
// rescheduling forever. update() re-kicks it on the next note.
|
||||
_rafId = null;
|
||||
_lastTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_rafId = requestAnimationFrame(_animateStrobe);
|
||||
}
|
||||
function _kick() {
|
||||
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
|
||||
}
|
||||
_rafId = requestAnimationFrame(_animateStrobe);
|
||||
|
||||
// ── Helper: derive octave number from frequency ───────────────
|
||||
@@ -439,6 +448,7 @@
|
||||
|
||||
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
|
||||
_currentCents = hasNote ? cents : 0;
|
||||
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
|
||||
}
|
||||
|
||||
// ── Public: destroy ───────────────────────────────────────────
|
||||
|
||||
@@ -142,9 +142,20 @@ window._tunerViz_strobe = function (container) {
|
||||
strobeEl.style.opacity = '0';
|
||||
}
|
||||
|
||||
// Idle the loop when there's no live signal — the strobe only needs to
|
||||
// paint while a note is sounding. update() re-kicks it on the next note,
|
||||
// so a silent tuner stops repainting instead of spinning at 60 fps.
|
||||
if (!strobeActive) { rafId = null; return; }
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
function _kick() {
|
||||
if (rafId === null) {
|
||||
lastAnimateTime = performance.now();
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(_animate);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────
|
||||
@@ -188,6 +199,7 @@ window._tunerViz_strobe = function (container) {
|
||||
const inTune = Math.abs(cents) < 5;
|
||||
strobeEl.style.opacity = inTune ? '1' : '0.6';
|
||||
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
|
||||
_kick();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -122,6 +122,26 @@
|
||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||
plungerEl.style.top = _topPct.toFixed(2) + '%';
|
||||
|
||||
// No live signal and the plunger has eased back to its resting centre
|
||||
// → idle the loop. update() re-kicks it on the next note.
|
||||
if (_currentNote === null && !_plungerDipped
|
||||
&& Math.abs(targetLeft - _leftPct) < 0.05) {
|
||||
_leftPct = targetLeft;
|
||||
plungerEl.style.left = _leftPct.toFixed(2) + '%';
|
||||
_rafId = null;
|
||||
_lastTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
function _kick() {
|
||||
if (_rafId !== null) return;
|
||||
// Already parked at rest with no signal → nothing to animate, stay idle.
|
||||
if (_currentNote === null && !_plungerDipped
|
||||
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
|
||||
_lastTime = null;
|
||||
_rafId = requestAnimationFrame(_animate);
|
||||
}
|
||||
|
||||
@@ -130,6 +150,7 @@
|
||||
_currentNote = note;
|
||||
_currentCents = note === null ? 0 : cents;
|
||||
if (!_plungerDipped) { noteEl.textContent = note || '–'; }
|
||||
_kick(); // a new reading may move the plunger → ensure the loop runs
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
|
||||
@@ -60,6 +60,8 @@ import yaml # noqa: E402
|
||||
|
||||
import notation as notation_mod # noqa: E402, F401 (re-exported for tests)
|
||||
|
||||
from jsonc import load_json # noqa: E402
|
||||
|
||||
# The wire→notation heuristic core lives in ``lib/notation_lift.py`` so it can
|
||||
# be reused in-process (e.g. by the Arrangement Editor's notation save path)
|
||||
# rather than being copy-pasted out of this one-time CLI. Re-exported here so
|
||||
@@ -106,15 +108,16 @@ def _parse_time_signature(raw: object) -> tuple[int, int]:
|
||||
|
||||
|
||||
def _load_song_beats(pak: Path, manifest: dict) -> list[dict]:
|
||||
"""Song-level beats: ``song_timeline.json`` when present, else the first
|
||||
"""Song-level beats: the ``song_timeline`` file when present, else the first
|
||||
arrangement JSON that carries a non-empty ``beats`` array (the loader's
|
||||
legacy convention)."""
|
||||
legacy convention). Both the timeline and arrangement files are read via
|
||||
``load_json``, so either may be ``.json`` or ``.jsonc``."""
|
||||
st_rel = manifest.get("song_timeline")
|
||||
if isinstance(st_rel, str) and st_rel:
|
||||
st_path = _safe_child(pak, st_rel)
|
||||
if st_path is not None and st_path.is_file():
|
||||
try:
|
||||
data = json.loads(st_path.read_text(encoding="utf-8"))
|
||||
data = load_json(st_path)
|
||||
# An empty beats list is not an authoritative timeline — fall
|
||||
# through to the arrangement JSONs rather than ending up with
|
||||
# zero downbeats and skipping the whole sloppak.
|
||||
@@ -134,7 +137,7 @@ def _load_song_beats(pak: Path, manifest: dict) -> list[dict]:
|
||||
if arr_path is None or not arr_path.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(arr_path.read_text(encoding="utf-8"))
|
||||
data = load_json(arr_path)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
@@ -219,7 +222,7 @@ def lift_sloppak(pak: Path, *, dry_run: bool = False) -> list[str]:
|
||||
continue
|
||||
|
||||
try:
|
||||
arr_data = json.loads(arr_path.read_text(encoding="utf-8"))
|
||||
arr_data = load_json(arr_path)
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning("%s/%s: unreadable arrangement JSON (%s) — skipped",
|
||||
pak.name, arr_id, e)
|
||||
|
||||
+299
-9
@@ -5990,17 +5990,104 @@ function _resolvePlayerOrigin() {
|
||||
// next song:ready. song:ready also fires on arrangement switches / seeks,
|
||||
// which never arm the flag, so those don't auto-restart.
|
||||
let _pendingAutostart = false;
|
||||
// Autoplay gate (window.feedBack.holdAutoplay): a plugin (the tuner) can defer the
|
||||
// auto-start of a freshly-loaded song until it's cleared — "tune before you play".
|
||||
// The hold is claimed synchronously on song:loading (so it beats this song:ready
|
||||
// autostart); release() — or a fail-open backstop — runs the deferred start.
|
||||
// Generation-guarded so a newer song invalidates a stale hold. Manual Play never
|
||||
// flows through here, so Play always wins.
|
||||
let _autoplayHeld = false;
|
||||
let _autoplayStart = null;
|
||||
let _autoplayGen = 0;
|
||||
let _autoplayBackstop = null;
|
||||
const AUTOPLAY_HOLD_BACKSTOP_MS = 12000;
|
||||
function _clearAutoplayHold() {
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
_autoplayHeld = false;
|
||||
_autoplayStart = null;
|
||||
_autoplayGen++;
|
||||
}
|
||||
function _releaseAutoplay(gen) {
|
||||
if (gen !== _autoplayGen) return; // a newer song superseded this hold
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
_autoplayHeld = false;
|
||||
const start = _autoplayStart;
|
||||
_autoplayStart = null;
|
||||
if (typeof start === 'function') start();
|
||||
}
|
||||
let _autoplayHoldToken = 0;
|
||||
window.feedBack.holdAutoplay = function () {
|
||||
const gen = _autoplayGen;
|
||||
const token = ++_autoplayHoldToken; // this hold's identity — a stale release from an earlier hold is a no-op
|
||||
_autoplayHeld = true;
|
||||
if (_autoplayBackstop) clearTimeout(_autoplayBackstop);
|
||||
// Fail-open: a hold that's never released (a plugin that claimed but wedged before
|
||||
// it could decide) must never permanently block the song. Once the holder commits
|
||||
// to an intentional, user-dismissable hold it calls release.settle() to cancel this
|
||||
// — so the backstop can't cut off e.g. a user still tuning past the timeout.
|
||||
_autoplayBackstop = setTimeout(() => _releaseAutoplay(gen), AUTOPLAY_HOLD_BACKSTOP_MS);
|
||||
let released = false;
|
||||
function release() {
|
||||
if (released || gen !== _autoplayGen || token !== _autoplayHoldToken) return;
|
||||
released = true;
|
||||
_releaseAutoplay(gen);
|
||||
}
|
||||
// Cancel the fail-open backstop WITHOUT releasing: the holder has taken explicit
|
||||
// responsibility for releasing (on dismiss), and a song switch clears the hold anyway.
|
||||
release.settle = function () {
|
||||
if (gen !== _autoplayGen || token !== _autoplayHoldToken) return;
|
||||
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
|
||||
};
|
||||
return release;
|
||||
};
|
||||
window.feedBack.on('song:ready', () => {
|
||||
if (!_pendingAutostart) return;
|
||||
_pendingAutostart = false;
|
||||
if (!_autoplayExitEnabled() || isPlaying) return;
|
||||
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
|
||||
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
|
||||
if (_countdownBeforeSongEnabled()) {
|
||||
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
|
||||
} else {
|
||||
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
|
||||
if (isPlaying) return;
|
||||
// Feedpak contributor credits: only real feedpak plays carry authors
|
||||
// (loose/archive and minigames get []), so a non-empty list is the gate.
|
||||
// Shown over the highway and dismissed the moment real playback begins
|
||||
// (song:play). This fresh-load path is the only place it fires —
|
||||
// arrangement switches / seeks / manual replays never arm _pendingAutostart,
|
||||
// and minigames never get here. Decoupled from autoplay below so credits
|
||||
// show on load even when autoplay-exit is disabled.
|
||||
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
|
||||
if (authors.length) {
|
||||
showSongCreditsOverlay(authors);
|
||||
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
|
||||
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
|
||||
}
|
||||
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
|
||||
// couple seconds on the freshly-loaded song, then clear them (they also
|
||||
// clear early if the user manually presses Play, via _creditsHideOnPlay).
|
||||
if (!_autoplayExitEnabled()) {
|
||||
if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
|
||||
return;
|
||||
}
|
||||
// The actual auto-start: a count-in (which handles HTML5 + _juceMode) or the
|
||||
// Play path directly. Guarded so a manual Play during a gate / credits hold
|
||||
// can't double-toggle, and so a stale (released-after-leaving) start never
|
||||
// begins playback off the player.
|
||||
const start = () => {
|
||||
if (isPlaying) return;
|
||||
if (!document.getElementById('player')?.classList.contains('active')) { hideSongCreditsOverlay(); return; }
|
||||
if (_countdownBeforeSongEnabled()) {
|
||||
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
|
||||
} else {
|
||||
Promise.resolve(togglePlay())
|
||||
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); })
|
||||
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
|
||||
}
|
||||
};
|
||||
// A plugin (the tuner) may gate playback until it's cleared. The hold was
|
||||
// claimed on song:loading; stash the start and let release()/the backstop run
|
||||
// it. _cancelCountIn()/changeArrangement() clear _creditsTimer below, so a
|
||||
// teardown during the credits dwell still cancels a non-gated play.
|
||||
if (_autoplayHeld) { _autoplayStart = start; return; }
|
||||
// Not gated: a count-in starts now (it owns its on-screen dwell); otherwise
|
||||
// let the credits dwell a couple seconds first, then start.
|
||||
if (_countdownBeforeSongEnabled() || !authors.length) start();
|
||||
else _creditsTimer = setTimeout(() => { _creditsTimer = null; start(); }, _CREDITS_HOLD_MS);
|
||||
});
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
@@ -6294,9 +6381,17 @@ let artAbortController = null;
|
||||
|
||||
async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
}
|
||||
// Invalidate any prior song's autoplay gate before plugins re-claim it on the
|
||||
// song:loading emit below.
|
||||
_clearAutoplayHold();
|
||||
window.feedBack.emit('song:loading', { filename, arrangement: arrangement ?? null });
|
||||
|
||||
// Cancel any pending art/metadata requests
|
||||
@@ -6395,6 +6490,11 @@ let _arrBusyTimeout = null;
|
||||
|
||||
async function changeArrangement(index) {
|
||||
if (currentFilename) {
|
||||
// Tear down any pending fresh-load credits before switching: the
|
||||
// no-count-in hold timer would otherwise fire togglePlay() against the
|
||||
// incoming (still-loading) arrangement. hideSongCreditsOverlay() clears
|
||||
// the timer, the song:play listener, and the overlay node.
|
||||
hideSongCreditsOverlay();
|
||||
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
|
||||
const wasPlaying = isPlaying;
|
||||
const time = _audioTime();
|
||||
@@ -6628,11 +6728,75 @@ if (window.feedBack) window.feedBack.restartCurrentSong = restartCurrentSong;
|
||||
// (Esc shortcut uses the same origin-aware target). showScreen() owns the
|
||||
// full teardown: song:stop, audio unload, highway.stop(), count-in cancel.
|
||||
function closeCurrentSong() {
|
||||
// A real close (user Escape/✕, or the queue-aware wrapper once the queue is
|
||||
// exhausted) abandons any play-queue so a stale one can't advance later.
|
||||
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.clear();
|
||||
return showScreen(_playerOriginScreen || 'home');
|
||||
}
|
||||
window.closeCurrentSong = closeCurrentSong;
|
||||
if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
||||
|
||||
// ── Play-queue: sequential playback of a playlist / album ──────────────────
|
||||
// Playing a list should advance to the next track when a song ends, instead of
|
||||
// returning to the menu (the long-standing "plays one song then boots to menu"
|
||||
// gap — a queue was simply never implemented). Advancing rides the SAME exit
|
||||
// choke point as auto-exit and a results-card close: window.closeCurrentSong().
|
||||
// Song-end paths call window.closeCurrentSong() (the auto-exit grace timer, and
|
||||
// a results screen's release()), so wrapping it lets the queue advance on song
|
||||
// end AND after the user dismisses a score card. A *user* exit (Escape / the ✕)
|
||||
// calls the bareword closeCurrentSong(), which we deliberately leave alone, so
|
||||
// leaving the player still leaves — and abandons the queue.
|
||||
window.feedBack.playQueue = (function () {
|
||||
let list = [], idx = -1, source = '', arrangements = null;
|
||||
const active = () => idx >= 0 && idx < list.length;
|
||||
const hasNext = () => active() && idx < list.length - 1;
|
||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||
function _play(i) {
|
||||
const fn = list[i];
|
||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||
}
|
||||
function start(files, opts) {
|
||||
files = (files || []).filter(Boolean);
|
||||
if (!files.length) return false;
|
||||
list = files.slice(); idx = 0;
|
||||
source = (opts && opts.source) || '';
|
||||
arrangements = (opts && opts.arrangements) || null;
|
||||
if (window.fbNotify) {
|
||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||
}
|
||||
_play(idx);
|
||||
return true;
|
||||
}
|
||||
function advance() {
|
||||
if (!hasNext()) { clear(); return false; }
|
||||
idx++;
|
||||
_play(idx);
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
};
|
||||
})();
|
||||
|
||||
// Make the song-end exit queue-aware (see above). Wrap window.closeCurrentSong
|
||||
// (and feedBack.closeCurrentSong) so that when a queue has a next track, we play
|
||||
// it instead of returning to the menu. The bareword closeCurrentSong() used by a
|
||||
// user-initiated exit is unaffected.
|
||||
(function () {
|
||||
const realClose = window.closeCurrentSong;
|
||||
function queueAwareClose() {
|
||||
const q = window.feedBack.playQueue;
|
||||
if (q && q.hasNext()) { q.advance(); return; }
|
||||
if (q) q.clear();
|
||||
return realClose.apply(this, arguments);
|
||||
}
|
||||
window.closeCurrentSong = queueAwareClose;
|
||||
if (window.feedBack) window.feedBack.closeCurrentSong = queueAwareClose;
|
||||
})();
|
||||
|
||||
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||
@@ -8675,12 +8839,24 @@ function _installSectionPracticeDismiss() {
|
||||
// inside #section-practice-control so it never self-closes. Listeners added
|
||||
// mid-dispatch don't fire for the opening click, so there's no immediate
|
||||
// close race.
|
||||
//
|
||||
// The click listener uses the CAPTURE phase: the v3 player rail's icon
|
||||
// buttons call e.stopPropagation() in their click handler (player-chrome.js
|
||||
// wireRail), which kills bubbling before it reaches document. A bubble-phase
|
||||
// outside-click dismiss would therefore never fire when the user clicks a
|
||||
// rail icon (Plugins, Audio, …) to open another popover, leaving this
|
||||
// popover stranded open on top of it. Capture runs before the target's
|
||||
// handler, so the stopPropagation can't swallow it. This mirrors the audio
|
||||
// mixer popover (audio-mixer.js), which dismisses outside-clicks the same
|
||||
// way. (Esc stays bubble-phase — no rail handler stops keydown propagation,
|
||||
// so it already reaches us, and capturing it would reorder it ahead of the
|
||||
// player's Escape-to-exit handling.)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!_sectionPracticePopoverOpen()) return;
|
||||
const ctrl = document.getElementById('section-practice-control');
|
||||
if (ctrl && ctrl.contains(e.target)) return;
|
||||
_closeSectionPracticePopover();
|
||||
});
|
||||
}, true);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) _closeSectionPracticePopover();
|
||||
});
|
||||
@@ -9285,10 +9461,27 @@ let _countOverlay = null;
|
||||
let _countInGen = 0;
|
||||
let _countInTimer = null;
|
||||
let _countInRaf = 0;
|
||||
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
|
||||
// highway when a song is loaded, alongside the count-in. Torn down together
|
||||
// with the count-in via _cancelCountIn().
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_HOLD_MS = 3000;
|
||||
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
|
||||
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
|
||||
// a count-in handoff that never plays). This hard cap guarantees the credits
|
||||
// never linger over the highway. Generous enough to outlast a normal count-in.
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
function _cancelCountIn() {
|
||||
_countInGen++;
|
||||
_countingIn = false;
|
||||
hideCountOverlay();
|
||||
// The credits overlay rides the count-in lifecycle (and its no-count-in
|
||||
// hold timer), so a teardown — leaving the player, loading another song —
|
||||
// must clear it too, or it lingers on the next screen.
|
||||
hideSongCreditsOverlay();
|
||||
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
|
||||
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
|
||||
}
|
||||
@@ -9306,6 +9499,92 @@ function hideCountOverlay() {
|
||||
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
|
||||
}
|
||||
|
||||
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
|
||||
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
|
||||
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
|
||||
const _CREDIT_ROLE_VERBS = {
|
||||
charter: 'Charted by',
|
||||
transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by',
|
||||
editor: 'Edited by',
|
||||
mixer: 'Mixed by',
|
||||
engineer: 'Engineered by',
|
||||
proofreader: 'Proofread by',
|
||||
};
|
||||
|
||||
function _creditLineLabel(role) {
|
||||
if (!role) return '';
|
||||
const key = String(role).trim().toLowerCase();
|
||||
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
|
||||
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
|
||||
}
|
||||
|
||||
// Show the feedpak contributor credits over the highway. `authors` is the
|
||||
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
|
||||
// Anchored to the lower third (bottom-center) so it never collides with the
|
||||
// vertically-centered count-in number, and pointer-events-none so it never
|
||||
// intercepts clicks. No-op when there are no contributors to show.
|
||||
function showSongCreditsOverlay(authors) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
if (!_creditsOverlay) {
|
||||
_creditsOverlay = document.createElement('div');
|
||||
_creditsOverlay.className = 'song-credits-overlay';
|
||||
document.body.appendChild(_creditsOverlay);
|
||||
}
|
||||
// Build via DOM + textContent — author names are untrusted pack data and
|
||||
// must never be interpolated as HTML.
|
||||
_creditsOverlay.replaceChildren();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'song-credits-card';
|
||||
|
||||
const eyebrow = document.createElement('div');
|
||||
eyebrow.className = 'song-credits-eyebrow';
|
||||
eyebrow.textContent = 'Credits';
|
||||
card.appendChild(eyebrow);
|
||||
|
||||
const title = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.title) || '';
|
||||
if (title) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'song-credits-heading';
|
||||
heading.textContent = title;
|
||||
card.appendChild(heading);
|
||||
}
|
||||
|
||||
for (const a of authors) {
|
||||
if (!a || !a.name) continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'song-credits-line';
|
||||
const label = _creditLineLabel(a.role);
|
||||
if (label) {
|
||||
const lab = document.createElement('span');
|
||||
lab.className = 'song-credits-role';
|
||||
lab.textContent = label + ' ';
|
||||
row.appendChild(lab);
|
||||
}
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'song-credits-name';
|
||||
nm.textContent = a.name;
|
||||
row.appendChild(nm);
|
||||
card.appendChild(row);
|
||||
}
|
||||
_creditsOverlay.appendChild(card);
|
||||
// Arm the backstop so the overlay self-clears even if playback never starts
|
||||
// / never emits song:play. song:play (or any teardown) clears it earlier.
|
||||
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
|
||||
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
|
||||
}
|
||||
|
||||
function hideSongCreditsOverlay() {
|
||||
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
|
||||
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
|
||||
if (_creditsHideOnPlay) {
|
||||
window.feedBack.off('song:play', _creditsHideOnPlay);
|
||||
_creditsHideOnPlay = null;
|
||||
}
|
||||
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
|
||||
}
|
||||
|
||||
async function startCountIn(opts = {}) {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
@@ -9498,6 +9777,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();
|
||||
@@ -9525,7 +9810,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
// Core "working tuning" capability domain — the live, host-authoritative CURRENT
|
||||
// instrument tuning (session state), distinct from the soft opt-in default and from
|
||||
// any one song's tuning. This is the single source of truth the whole app reads:
|
||||
// the highway, the library/song-picker, Virtuoso, and the minigames all consult it,
|
||||
// and the tuner is the sole WRITER (it updates this when the player retunes, clears
|
||||
// the gate, or switches instruments).
|
||||
//
|
||||
// PER-INSTRUMENT: a player has separate physical instruments, each in its OWN tuning
|
||||
// ("I'm not tuning two instruments when I pick a song"). So state is a MAP keyed by
|
||||
// instrument — `${instrument}-${stringCount}` (e.g. "guitar-6", "bass-4"), the same key
|
||||
// the v3 instrument selector uses. `get()` returns the CURRENTLY-SELECTED instrument's
|
||||
// tuning; switching the selector surfaces that instrument's own remembered tuning. You
|
||||
// only ever deal with the one you've picked.
|
||||
//
|
||||
// Design: WORKING-TUNING-STATE-DESIGN.md (host-first PR series, PR 1 = this file).
|
||||
// Pattern mirrors `capabilities/tuning.js` (capability registration) + the host theme
|
||||
// read-API (`window.feedBack.theme`): a synchronous `get()` plus a `working-tuning-
|
||||
// changed` event that also fires once on hydration.
|
||||
//
|
||||
// State is IN-MEMORY and NOT persisted — reset-to-home on restart is deliberate (a
|
||||
// stale "you're in drop-A" assumption is worse than re-asking). The opt-in "default
|
||||
// tuning on app open" lands later; for now we seed the selected instrument from
|
||||
// /api/settings.
|
||||
//
|
||||
// PR 1 is PURE PLUMBING: it introduces the state + read/write surface + event, but
|
||||
// nothing writes to it yet and no behavior changes. The tuner becomes the writer (and
|
||||
// the gate's E->C# asymmetry is fixed) in a later PR.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
// Idempotent: a second injection of this module must not replace the live state
|
||||
// with a fresh (empty) one — once we're registered, re-running is a no-op.
|
||||
if (window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1) return;
|
||||
const capabilities = window.feedBack.capabilities;
|
||||
|
||||
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
|
||||
let _currentKey = null; // the selected instrument's key; cached so get() is sync
|
||||
let _hydrated = false;
|
||||
let _touched = false; // set once anything explicitly writes/selects; gates the async seed
|
||||
|
||||
function _normInstrument(instrument) {
|
||||
return instrument === 'bass' ? 'bass' : 'guitar';
|
||||
}
|
||||
function _keyOf(instrument, stringCount) {
|
||||
const inst = _normInstrument(instrument);
|
||||
const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6);
|
||||
return inst + '-' + sc;
|
||||
}
|
||||
// Like _keyOf, but when the caller omits a string count we resolve it against the
|
||||
// current selection (if it's the same instrument) before falling back to the
|
||||
// per-instrument default — so `set({instrument:'bass'})` targets the selected
|
||||
// bass-5, not a hard-coded bass-4.
|
||||
function _keyOfResolved(instrument, stringCount) {
|
||||
const inst = _normInstrument(instrument);
|
||||
let sc = Number(stringCount);
|
||||
if (!sc) {
|
||||
if (_currentKey) {
|
||||
const cur = _splitKey(_currentKey);
|
||||
if (cur.instrument === inst) sc = cur.stringCount;
|
||||
}
|
||||
if (!sc) sc = (inst === 'bass' ? 4 : 6);
|
||||
}
|
||||
return inst + '-' + sc;
|
||||
}
|
||||
function _splitKey(key) {
|
||||
const parts = (typeof key === 'string' ? key : '').split('-');
|
||||
const inst = parts[0] === 'bass' ? 'bass' : 'guitar';
|
||||
return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) };
|
||||
}
|
||||
|
||||
// The shape every consumer reads. `offsets` are per-string semitone offsets from
|
||||
// standard (same vocabulary as song_info.tuning and /api/tunings); `instrument`
|
||||
// disambiguates the open-string base so offsets resolve to real pitches. A drop-A
|
||||
// 8-string is just an offsets array — fully custom tunings are first-class.
|
||||
// `provenance` is the honesty flag: 'verified' means the tuner did a choreographed
|
||||
// per-string mic check this session; everything else is 'assumed'.
|
||||
function _defaultState(key) {
|
||||
const id = _splitKey(key);
|
||||
return {
|
||||
offsets: null,
|
||||
stringCount: id.stringCount,
|
||||
instrument: id.instrument,
|
||||
referencePitch: 440,
|
||||
provenance: 'assumed',
|
||||
verifiedStrings: null,
|
||||
verifiedAt: null,
|
||||
source: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve which instrument key a get/set targets: an explicit arg wins (a string
|
||||
// key "guitar-6", a bare "guitar"/"bass", or { instrument, stringCount }); else the
|
||||
// cached current selection.
|
||||
function _resolveKey(instrument) {
|
||||
if (instrument && typeof instrument === 'object') return _keyOfResolved(instrument.instrument, instrument.stringCount);
|
||||
if (typeof instrument === 'string' && instrument) {
|
||||
return instrument.indexOf('-') > 0 ? instrument : _keyOfResolved(instrument, null);
|
||||
}
|
||||
return _currentKey || _keyOf('guitar', 6);
|
||||
}
|
||||
|
||||
// Synchronous read of an instrument's current tuning (default = selected
|
||||
// instrument). Returns a deep-enough copy — the object plus its mutable array
|
||||
// fields (`offsets`, `verifiedStrings`) — so a reader can't mutate the live state.
|
||||
function get(instrument) {
|
||||
const key = _resolveKey(instrument);
|
||||
const state = Object.assign(_defaultState(key), _byInstrument[key] || {});
|
||||
if (Array.isArray(state.offsets)) state.offsets = state.offsets.slice();
|
||||
if (Array.isArray(state.verifiedStrings)) state.verifiedStrings = state.verifiedStrings.slice();
|
||||
return state;
|
||||
}
|
||||
|
||||
function _emitChanged(key) {
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
window.feedBack.emit('working-tuning-changed', { key: key, instrument: _splitKey(key).instrument, tuning: get(key) });
|
||||
}
|
||||
}
|
||||
|
||||
// The single mutator. The tuner calls this on retune / gate-clear / swap. Writes to
|
||||
// the instrument the state targets (opts.instrument, or next.instrument+stringCount,
|
||||
// or the current selection) and makes that the active instrument. `opts.provenance`
|
||||
// stamps 'verified' (mic-confirmed) vs the default 'assumed'. Changing the tuning
|
||||
// invalidates a prior verification unless fresh verifiedStrings are supplied — fail
|
||||
// toward "assumed".
|
||||
function set(next, opts) {
|
||||
opts = opts || {};
|
||||
next = next || {};
|
||||
// Resolve the target key. An explicit opts.instrument wins; otherwise a
|
||||
// next.instrument/next.stringCount targets that slot — but a bare stringCount
|
||||
// (no instrument) applies to the CURRENTLY-SELECTED instrument, not a hard-coded
|
||||
// guitar, so `set({stringCount:5})` on a selected bass writes bass-5.
|
||||
let key;
|
||||
if (opts.instrument) {
|
||||
key = _resolveKey(opts.instrument);
|
||||
} else if (next.instrument || next.stringCount) {
|
||||
const inst = next.instrument ? _normInstrument(next.instrument)
|
||||
: (_currentKey ? _splitKey(_currentKey).instrument : 'guitar');
|
||||
key = _keyOfResolved(inst, next.stringCount);
|
||||
} else {
|
||||
key = _currentKey || _resolveKey();
|
||||
}
|
||||
const id = _splitKey(key);
|
||||
const merged = Object.assign(get(key), next); // get() gives copies, so `merged` is ours to mutate
|
||||
merged.instrument = id.instrument; // keep coherent with the key
|
||||
merged.stringCount = id.stringCount; // the key is authoritative for string count
|
||||
const tuningChanged = ('offsets' in next) || ('stringCount' in next) || ('referencePitch' in next);
|
||||
|
||||
// Provenance: explicit opts wins; a bare tuning change downgrades to 'assumed'.
|
||||
if (opts.provenance) {
|
||||
merged.provenance = opts.provenance;
|
||||
} else if (tuningChanged) {
|
||||
merged.provenance = 'assumed';
|
||||
}
|
||||
|
||||
// Verification metadata is coherent by construction: a tuning change invalidates
|
||||
// prior per-string verification unless the caller supplies a fresh bundle, and the
|
||||
// metadata exists ONLY while provenance === 'verified'. So verified <=> we hold
|
||||
// verifiedStrings — a "verified with no strings" state is impossible.
|
||||
if (!('verifiedStrings' in next) && tuningChanged) {
|
||||
merged.verifiedStrings = null;
|
||||
}
|
||||
if (merged.provenance === 'verified' && !Array.isArray(merged.verifiedStrings)) {
|
||||
merged.provenance = 'assumed'; // claimed verified but no evidence — fail toward assumed
|
||||
}
|
||||
if (merged.provenance === 'verified') {
|
||||
// verified always carries a real timestamp — a caller-supplied null/NaN/absent
|
||||
// verifiedAt is stamped now, so 'verified' can never mean "at no known time".
|
||||
if (typeof merged.verifiedAt !== 'number' || !isFinite(merged.verifiedAt)) {
|
||||
merged.verifiedAt = Date.now();
|
||||
}
|
||||
} else {
|
||||
merged.verifiedStrings = null;
|
||||
merged.verifiedAt = null;
|
||||
}
|
||||
|
||||
// Store copies of the mutable arrays so a caller can't mutate live state post-set.
|
||||
if (Array.isArray(merged.offsets)) merged.offsets = merged.offsets.slice();
|
||||
if (Array.isArray(merged.verifiedStrings)) merged.verifiedStrings = merged.verifiedStrings.slice();
|
||||
_byInstrument[key] = merged;
|
||||
_currentKey = key; // writing a tuning makes that instrument the active one
|
||||
_touched = true; // an explicit write must not be clobbered by the async seed
|
||||
_emitChanged(key);
|
||||
return get(key);
|
||||
}
|
||||
|
||||
// Tell the host which instrument is now selected (the v3 selector calls this when
|
||||
// the player switches guitar<->bass / string count) so get() returns the right
|
||||
// instrument's tuning. Emits if the selection actually changed.
|
||||
function setCurrentInstrument(instrument, stringCount) {
|
||||
const key = (typeof instrument === 'string' && instrument.indexOf('-') > 0) ? instrument : _keyOfResolved(instrument, stringCount);
|
||||
_touched = true; // an explicit selection must not be reverted by the async seed
|
||||
if (key === _currentKey) return get(key);
|
||||
_currentKey = key;
|
||||
_emitChanged(key);
|
||||
return get(key);
|
||||
}
|
||||
|
||||
// Reset an instrument's live tuning back to its baseline (the home/default).
|
||||
function resetToDefault(instrument) {
|
||||
const key = _resolveKey(instrument);
|
||||
_byInstrument[key] = _defaultState(key);
|
||||
_touched = true;
|
||||
_emitChanged(key);
|
||||
return get(key);
|
||||
}
|
||||
|
||||
// Per-string semitone offsets of a named tuning relative to Standard, derived from
|
||||
// the /api/tunings frequency tables. The reference pitch cancels in the ratio, so
|
||||
// this is pitch-independent. Returns null if either row is missing/mismatched.
|
||||
function _offsetsFromFreqs(named, standard) {
|
||||
if (!Array.isArray(named) || !Array.isArray(standard) || named.length !== standard.length) return null;
|
||||
const out = [];
|
||||
for (let i = 0; i < named.length; i++) {
|
||||
const a = Number(named[i]);
|
||||
const b = Number(standard[i]);
|
||||
if (!(a > 0) || !(b > 0)) return null;
|
||||
out.push(Math.round(12 * Math.log2(a / b)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Opt-in "launch tuning" default (soft, per-instrument) -------------------
|
||||
// A convenience the player opts into: "start me in THIS tuning on app open." Off
|
||||
// by default (nothing stored) → boot seeds from /api/settings as before. It is
|
||||
// only a SEED — the live working tuning still resets on restart.
|
||||
const LAUNCH_KEY = 'v3-working-tuning-launch-default';
|
||||
function _readLaunchMap() {
|
||||
try { return JSON.parse(localStorage.getItem(LAUNCH_KEY) || '{}') || {}; }
|
||||
catch (_) { return {}; }
|
||||
}
|
||||
function _writeLaunchMap(map) {
|
||||
try {
|
||||
if (map && Object.keys(map).length) localStorage.setItem(LAUNCH_KEY, JSON.stringify(map));
|
||||
else localStorage.removeItem(LAUNCH_KEY);
|
||||
} catch (_) { /* private mode */ }
|
||||
}
|
||||
function getLaunchDefault(instrument) {
|
||||
const key = _resolveKey(instrument);
|
||||
const d = _readLaunchMap()[key];
|
||||
return d ? Object.assign(_defaultState(key), d, { source: 'launch-default' }) : null;
|
||||
}
|
||||
// Remember an instrument's CURRENT working tuning (or a supplied state) as its
|
||||
// launch default. Opt-in — nothing calls this unless the player asks.
|
||||
function setLaunchDefault(instrument, state) {
|
||||
const key = _resolveKey(instrument);
|
||||
const src = state || get(key);
|
||||
const map = _readLaunchMap();
|
||||
map[key] = {
|
||||
offsets: Array.isArray(src.offsets) ? src.offsets.slice() : null,
|
||||
stringCount: src.stringCount,
|
||||
instrument: _splitKey(key).instrument,
|
||||
referencePitch: src.referencePitch || 440,
|
||||
};
|
||||
_writeLaunchMap(map);
|
||||
return getLaunchDefault(key);
|
||||
}
|
||||
function clearLaunchDefault(instrument) {
|
||||
const key = _resolveKey(instrument);
|
||||
const map = _readLaunchMap();
|
||||
if (key in map) { delete map[key]; _writeLaunchMap(map); }
|
||||
}
|
||||
|
||||
// Seed the SELECTED instrument's slot on boot (best-effort 'assumed' starting
|
||||
// point, NOT a persisted working tuning): the player's opt-in launch default if one
|
||||
// is set for this instrument, else /api/settings — where a NAMED tuning ("Drop D") is
|
||||
// resolved to offsets via /api/tunings so it isn't lost. If neither can be read we
|
||||
// still hydrate so consumers aren't stuck waiting; an explicit set()/select before we
|
||||
// resolve wins (no clobber).
|
||||
function _seedFromSettings() {
|
||||
fetch('/api/settings')
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (s) {
|
||||
if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber
|
||||
const inst = _normInstrument(s.instrument);
|
||||
const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6);
|
||||
const key = _keyOf(inst, sc);
|
||||
|
||||
function commit(offsets) {
|
||||
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
|
||||
_currentKey = key;
|
||||
// Opt-in launch default wins over the raw profile; otherwise use the
|
||||
// resolved `offsets` (a named settings tuning was already turned into
|
||||
// offsets via /api/tunings before commit()).
|
||||
const launch = _readLaunchMap()[key];
|
||||
_byInstrument[key] = launch
|
||||
? {
|
||||
offsets: Array.isArray(launch.offsets) ? launch.offsets.slice(0, sc) : null,
|
||||
stringCount: sc, instrument: inst,
|
||||
referencePitch: Number(launch.referencePitch) || 440,
|
||||
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
|
||||
source: 'launch-default',
|
||||
}
|
||||
: {
|
||||
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
|
||||
stringCount: sc, instrument: inst,
|
||||
referencePitch: Number(s.reference_pitch) || 440,
|
||||
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
|
||||
source: 'settings',
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
|
||||
if (typeof s.tuning === 'string' && s.tuning) {
|
||||
return fetch('/api/tunings')
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (t) {
|
||||
const byName = t && t[key];
|
||||
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
|
||||
})
|
||||
.catch(function () { commit(null); });
|
||||
}
|
||||
commit(null);
|
||||
})
|
||||
.catch(function () { /* keep defaults */ })
|
||||
.then(function () { _hydrate(); });
|
||||
}
|
||||
|
||||
function _hydrate() {
|
||||
if (_hydrated) return;
|
||||
_hydrated = true;
|
||||
_emitChanged(_currentKey || _resolveKey());
|
||||
}
|
||||
|
||||
// A per-string mic verification is only trustworthy for the context it was done
|
||||
// in — a new song means the player may have retuned, so a stale 'verified' must
|
||||
// never suppress a needed prompt (fail toward re-checking). Decay the CURRENT
|
||||
// instrument's verification back to 'assumed' on each song load; offsets are kept.
|
||||
function _decayVerifiedOnSongLoad() {
|
||||
const key = _currentKey || _resolveKey();
|
||||
const st = _byInstrument[key];
|
||||
if (st && st.provenance === 'verified') {
|
||||
st.provenance = 'assumed';
|
||||
st.verifiedStrings = null;
|
||||
st.verifiedAt = null;
|
||||
_emitChanged(key);
|
||||
}
|
||||
}
|
||||
if (typeof window.feedBack.on === 'function') {
|
||||
window.feedBack.on('song:loading', _decayVerifiedOnSongLoad);
|
||||
}
|
||||
|
||||
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
|
||||
if (capabilities && capabilities.version === 1 &&
|
||||
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
|
||||
capabilities.registerOwner('working-tuning', {
|
||||
description: 'The live, host-authoritative current instrument tuning (session state), per ' +
|
||||
'instrument: offsets + string-count + reference pitch + assumed/verified provenance. ' +
|
||||
'Written by the tuner, read by the highway/library/Virtuoso/minigames.',
|
||||
operations: ['get-working-tuning', 'set-working-tuning'],
|
||||
events: ['working-tuning-changed'],
|
||||
kind: 'command',
|
||||
ownership: 'exclusive-owner',
|
||||
});
|
||||
capabilities.registerParticipant('plugin.tuner', {
|
||||
'working-tuning': {
|
||||
roles: ['contributor', 'requester'],
|
||||
operations: ['get-working-tuning', 'set-working-tuning'],
|
||||
emits: ['working-tuning-changed'],
|
||||
mode: 'active',
|
||||
compatibility: 'none',
|
||||
safety: 'safe',
|
||||
},
|
||||
});
|
||||
capabilities.registerParticipant('core.settings.instruments', {
|
||||
'working-tuning': {
|
||||
roles: ['requester'],
|
||||
operations: ['get-working-tuning'],
|
||||
events: ['working-tuning-changed'],
|
||||
mode: 'active',
|
||||
compatibility: 'none',
|
||||
safety: 'safe',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Public read/write surface (attached defensively, like feedBack.theme) ----
|
||||
window.feedBack.workingTuning = Object.freeze({
|
||||
version: 1,
|
||||
get: get,
|
||||
set: set,
|
||||
setCurrentInstrument: setCurrentInstrument,
|
||||
resetToDefault: resetToDefault,
|
||||
getLaunchDefault: getLaunchDefault,
|
||||
setLaunchDefault: setLaunchDefault,
|
||||
clearLaunchDefault: clearLaunchDefault,
|
||||
});
|
||||
|
||||
_seedFromSettings();
|
||||
})();
|
||||
+176
-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();
|
||||
@@ -3530,6 +3597,13 @@ function createHighway() {
|
||||
// matchesArrangement on this rather than the
|
||||
// arrangement name.
|
||||
hasNotation: Boolean(msg.has_notation),
|
||||
// Feedpak contributor credits (manifest
|
||||
// `authors:`, spec §5.4): [{name, role}].
|
||||
// Only real feedpak plays carry these; loose/
|
||||
// archive sources and synthetic highway uses
|
||||
// (minigames) get []. app.js shows a credits
|
||||
// overlay on song load when this is non-empty.
|
||||
authors: Array.isArray(msg.authors) ? msg.authors : [],
|
||||
};
|
||||
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
|
||||
}
|
||||
@@ -3764,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
|
||||
@@ -3772,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; },
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<script src="/static/capabilities.js"></script>
|
||||
<script src="/static/capabilities/library.js"></script>
|
||||
<script src="/static/capabilities/tuning.js"></script>
|
||||
<script src="/static/capabilities/working-tuning.js"></script>
|
||||
<script src="/static/capabilities/audio-session.js"></script>
|
||||
<script src="/static/capabilities/audio-effects.js"></script>
|
||||
<script src="/static/capabilities/playback.js"></script>
|
||||
|
||||
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
|
||||
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Feedpak contributor credits shown over the highway when a song loads
|
||||
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
|
||||
ABOVE the vertically-centered count-in number; click-through. */
|
||||
.song-credits-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 15%;
|
||||
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
|
||||
the count-in number (z-[100]) so the credits stay prominent through the
|
||||
whole load → count-in → play window. */
|
||||
z-index: 205;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.song-credits-card {
|
||||
position: relative;
|
||||
min-width: 16rem;
|
||||
max-width: min(90vw, 34rem);
|
||||
padding: 1.4rem 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
background:
|
||||
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
|
||||
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
|
||||
border: 1px solid rgb(129 140 248 / 0.28);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 18px 50px rgb(0 0 0 / 0.55),
|
||||
0 0 0 1px rgb(0 0 0 / 0.35),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Accent bar across the top edge of the card. */
|
||||
.song-credits-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 3.25rem;
|
||||
height: 3px;
|
||||
border-radius: 0 0 3px 3px;
|
||||
background: linear-gradient(90deg, #38bdf8, #818cf8);
|
||||
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
|
||||
}
|
||||
|
||||
.song-credits-eyebrow {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgb(165 180 252 / 0.9);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.song-credits-heading {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.7rem;
|
||||
letter-spacing: 0.01em;
|
||||
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.song-credits-line {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.55;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.song-credits-role {
|
||||
color: rgb(148 163 184 / 0.95);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.song-credits-name {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@keyframes song-credits-fade-in {
|
||||
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
+147
-5
@@ -25,6 +25,13 @@
|
||||
// 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 = {};
|
||||
// Last instrument-coverage report for the current song (from the tuner plugin) —
|
||||
// drives a passive "different tuning" cue on the tuner badge. null = covered /
|
||||
// unknown / off the player.
|
||||
let _lastCoverageReport = null;
|
||||
// Monotonic token so a slow coverage fetch can't restore a stale cue after a newer
|
||||
// song started loading / we left the player. Bumped on every refresh and clear.
|
||||
let _coverageCueToken = 0;
|
||||
function _tuningsForKey(key) { return Object.keys(_tuningsByKey[key] || {}); }
|
||||
function _tuningsForInstrument(instrument, string_count) {
|
||||
return _tuningsForKey(instrument + '-' + string_count);
|
||||
@@ -53,6 +60,52 @@
|
||||
return typeof settings.tuning === 'string' ? settings.tuning : 'Custom';
|
||||
}
|
||||
|
||||
// The SELECTED instrument's live working tuning (host `workingTuning` capability).
|
||||
// Feature-detected: returns null when the host doesn't expose it, so the card
|
||||
// quietly falls back to the profile tuning. `offsets` are named via the shared
|
||||
// displayTuningName resolver; "home" = still in the profile/default tuning.
|
||||
function workingTuningInfo() {
|
||||
var wt = window.feedBack && window.feedBack.workingTuning;
|
||||
var st = wt && typeof wt.get === 'function' ? wt.get() : null;
|
||||
if (!st) return null;
|
||||
var offsets = Array.isArray(st.offsets) ? st.offsets : null;
|
||||
var nameFor = window.displayTuningName
|
||||
|| (window.feedBack && window.feedBack.displayTuningName);
|
||||
// Resolve BOTH the home tuning and the working tuning through the SAME namer so
|
||||
// "home?" is a like-for-like comparison — comparing a raw settings string ('Custom'
|
||||
// / 'E Standard') against a from-offsets name would mislabel a real home tuning.
|
||||
var homeLabel = (typeof nameFor === 'function')
|
||||
? (nameFor(typeof settings.tuning === 'string' ? settings.tuning : null,
|
||||
Array.isArray(settings.tuning) ? settings.tuning : null) || tuningLabel())
|
||||
: tuningLabel();
|
||||
var label = (offsets && typeof nameFor === 'function') ? nameFor(null, offsets) : homeLabel;
|
||||
return {
|
||||
label: label,
|
||||
short: label.replace(/ Standard\b/, ' Std').replace(/Custom Tuning/, 'Custom'),
|
||||
// Home = no explicit working offsets, OR the working tuning names the same as
|
||||
// the profile's home tuning (both via `nameFor`, so the compare is consistent).
|
||||
isHome: !offsets || label === homeLabel,
|
||||
provenance: st.provenance === 'verified' ? 'verified' : 'assumed',
|
||||
};
|
||||
}
|
||||
|
||||
// Honesty glyph: a hollow diamond for an assumed tuning, a filled one for a
|
||||
// per-string mic-verified tuning (see the workingTuning provenance flag).
|
||||
function provenanceGlyph(p) {
|
||||
return p === 'verified'
|
||||
? '<span title="Verified by a per-string mic check" class="text-emerald-400">◆</span>'
|
||||
: '<span title="Assumed — not mic-verified" class="text-fb-textDim">◇</span>';
|
||||
}
|
||||
|
||||
// Tell the host which instrument is now selected, so workingTuning.get()
|
||||
// surfaces THIS instrument's own remembered tuning. No-op without the capability.
|
||||
function setWorkingInstrument(inst, sc) {
|
||||
var wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (wt && typeof wt.setCurrentInstrument === 'function') {
|
||||
try { wt.setCurrentInstrument(inst, sc); } catch (_) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
|
||||
async function loadTunings() {
|
||||
@@ -122,13 +175,14 @@
|
||||
accepted = !(body && body.error);
|
||||
}
|
||||
} catch (e) { /* non-fatal — leave settings unchanged */ }
|
||||
if (!accepted) return;
|
||||
if (!accepted) return false;
|
||||
Object.assign(settings, patch);
|
||||
if (sm && sm.emit) sm.emit('instrument:changed', {
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
|
||||
});
|
||||
pushToTuner();
|
||||
renderTuner(); // reflect new tuning on the tuner card
|
||||
return true;
|
||||
}
|
||||
|
||||
// Drive the tuner plugin's instrument + tuning from the selection.
|
||||
@@ -257,6 +311,52 @@
|
||||
openTuner();
|
||||
});
|
||||
_applyFrame(_lastFrame);
|
||||
_applyCoverageCue(_lastCoverageReport);
|
||||
}
|
||||
|
||||
// Passive "different tuning" cue on the tuner badge: an amber ring + a tooltip
|
||||
// naming the retune (e.g. "B→A"). The diff comes from the tuner plugin's coverage
|
||||
// report; an absent plugin or a covered song → no cue. CSS-free (inline ring +
|
||||
// native title) so it needs no Tailwind rebuild, and it never auto-opens the
|
||||
// panel — it's advisory; the user taps the badge to tune.
|
||||
function _applyCoverageCue(report) {
|
||||
const btn = document.querySelector('#v3-badge-tuner [data-open-tuner]');
|
||||
if (!btn) return;
|
||||
const needs = !!(report && !report.covered);
|
||||
btn.style.boxShadow = needs ? '0 0 0 2px #fbbf24' : '';
|
||||
if (!needs) { btn.title = 'Open tuner'; return; }
|
||||
const summary = report.cantCover ? 'a different instrument'
|
||||
: (report.retune && report.retune.length)
|
||||
? report.retune.map((d) => d.from + '→' + d.to).join(', ')
|
||||
: 'the reference pitch';
|
||||
btn.title = 'This song needs a different tuning — retune ' + summary + '. Click to tune.';
|
||||
}
|
||||
|
||||
// A coverage report only drives the cue when it carries an actual signal: covered
|
||||
// (clears the ring) or a nameable mismatch (retune / reference / cantCover). The
|
||||
// plugin returns a conservative all-false report on a fetch hiccup / missing data —
|
||||
// that's "unknown", NOT "needs retune", so collapse it to null (no cue) rather than
|
||||
// painting an amber "retune the reference pitch" ring with no evidence.
|
||||
function _meaningfulReport(report) {
|
||||
if (!report) return null;
|
||||
if (report.covered) return report;
|
||||
if (report.cantCover || report.reference || (report.retune && report.retune.length)) return report;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _refreshCoverageCue() {
|
||||
const myToken = ++_coverageCueToken;
|
||||
const songInfo = window.highway && window.highway.getSongInfo && window.highway.getSongInfo();
|
||||
const api = window._tunerAutoOpen;
|
||||
if (!songInfo || !api || typeof api.coverageReport !== 'function') {
|
||||
_lastCoverageReport = null; _applyCoverageCue(null); return;
|
||||
}
|
||||
let report = null;
|
||||
try { report = await api.coverageReport(songInfo); }
|
||||
catch (_e) { report = null; }
|
||||
if (myToken !== _coverageCueToken) return; // superseded by a newer song / a clear
|
||||
_lastCoverageReport = _meaningfulReport(report);
|
||||
_applyCoverageCue(_lastCoverageReport);
|
||||
}
|
||||
|
||||
// ── Instrument selector card (Stitch RightInstrumentSelector) ──────────--
|
||||
@@ -290,14 +390,29 @@
|
||||
function renderInstrument() {
|
||||
const host = document.getElementById('v3-badge-instrument');
|
||||
if (!host) return;
|
||||
const wt = workingTuningInfo();
|
||||
host.innerHTML =
|
||||
'<div id="v3-instrument-wrap" class="relative">' +
|
||||
'<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + tuningLabel()) + '" ' +
|
||||
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-2 hover:ring-1 hover:ring-fb-primary/40 transition">' +
|
||||
'<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + (wt ? wt.label : tuningLabel())) + '" ' +
|
||||
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-1.5 hover:ring-1 hover:ring-fb-primary/40 transition">' +
|
||||
guitarIcon +
|
||||
// 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.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>' +
|
||||
'<div data-inst-menu class="hidden absolute right-0 mt-2 w-60 bg-fb-card border border-fb-border/50 rounded-xl shadow-xl p-3 z-50 space-y-3">' +
|
||||
// "Now in" banner + one-tap back-to-default — shown only once you've
|
||||
// retuned off your home tuning (workingTuning present and not home).
|
||||
((wt && !wt.isHome)
|
||||
? '<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>' +
|
||||
'</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) =>
|
||||
@@ -335,20 +450,32 @@
|
||||
// unsupported instrument+tuning combo.
|
||||
const newSc = counts.includes(settings.string_count) ? settings.string_count : counts[0];
|
||||
const tunings = _tuningsForInstrument(v, newSc);
|
||||
await saveSettings({
|
||||
const ok = await saveSettings({
|
||||
instrument: v,
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
});
|
||||
// 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
|
||||
// new one's tuning (settings and workingTuning desync on a rejected save).
|
||||
if (ok) setWorkingInstrument(v, newSc); // surface THIS instrument's own working tuning
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
|
||||
await saveSettings({ string_count: Number(b.getAttribute('data-val')) }); renderInstrument(); keepOpen();
|
||||
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
|
||||
setWorkingInstrument(settings.instrument, settings.string_count);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: 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) }));
|
||||
const resetBtn = menu.querySelector('[data-inst-reset]');
|
||||
if (resetBtn) resetBtn.addEventListener('click', () => {
|
||||
const wtCap = window.feedBack && window.feedBack.workingTuning;
|
||||
if (wtCap && typeof wtCap.resetToDefault === 'function') wtCap.resetToDefault();
|
||||
renderInstrument(); keepOpen();
|
||||
});
|
||||
}
|
||||
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>';
|
||||
@@ -362,6 +489,11 @@
|
||||
|
||||
async function boot() {
|
||||
await Promise.all([loadTunings(), loadSettings()]);
|
||||
// NB: we deliberately do NOT call setWorkingInstrument() here. The host seeds its
|
||||
// current instrument from the same /api/settings on boot and emits
|
||||
// working-tuning-changed on hydration (which re-renders this card), so the card
|
||||
// aligns without us pre-touching the state — calling setCurrentInstrument() early
|
||||
// would set the capability's `_touched` flag and suppress that seed.
|
||||
renderInstrument();
|
||||
renderTuner();
|
||||
pushToTuner(); // sync the tuner to the persisted selection on load
|
||||
@@ -374,6 +506,16 @@
|
||||
await loadTunings();
|
||||
renderInstrument();
|
||||
});
|
||||
// Passive coverage cue: recompute when a song is ready; clear when a new
|
||||
// song starts loading or we leave the player screen.
|
||||
sm.on('song:ready', () => { _refreshCoverageCue(); });
|
||||
sm.on('song:loading', () => { _coverageCueToken++; _lastCoverageReport = null; _applyCoverageCue(null); });
|
||||
sm.on('screen:changed', (e) => {
|
||||
if (!e || !e.detail || e.detail.id !== 'player') { _coverageCueToken++; _lastCoverageReport = null; _applyCoverageCue(null); }
|
||||
});
|
||||
// The tuner (or a reset) changed the live working tuning → re-render the
|
||||
// card label + the "Now in" banner. No-op if the host lacks the capability.
|
||||
sm.on('working-tuning-changed', () => renderInstrument());
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
|
||||
+27
-5
@@ -87,6 +87,7 @@
|
||||
<script src="/static/capabilities.js"></script>
|
||||
<script src="/static/capabilities/library.js"></script>
|
||||
<script src="/static/capabilities/tuning.js"></script>
|
||||
<script src="/static/capabilities/working-tuning.js"></script>
|
||||
<script src="/static/capabilities/audio-session.js"></script>
|
||||
<script src="/static/capabilities/audio-effects.js"></script>
|
||||
<script src="/static/capabilities/playback.js"></script>
|
||||
@@ -343,7 +344,10 @@
|
||||
|
||||
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
|
||||
<div id="settings" class="screen">
|
||||
<div class="fb-settings">
|
||||
<!-- fb-selectable: Settings is read-only content (paths, device names,
|
||||
version, diagnostics, About) the user copies — opt the whole panel
|
||||
back in under the v3 non-select default. See static/v3/v3.css. -->
|
||||
<div class="fb-settings fb-selectable">
|
||||
<button onclick="showScreen('home')" class="fb-settings-back">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
|
||||
</button>
|
||||
@@ -825,7 +829,13 @@
|
||||
|
||||
<!-- Top HUD — persistent (song info, time, Up Next) -->
|
||||
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
|
||||
<div class="text-sm leading-tight">
|
||||
<!-- fb-selectable: now-playing song metadata (title / artist /
|
||||
arrangement / tuning) is copy-worthy content, not chrome.
|
||||
pointer-events-auto: the #player-hud parent is pointer-events-none
|
||||
(so the HUD doesn't eat clicks meant for the highway), which would
|
||||
also block the mouse from reaching this text to select it — opt
|
||||
just this block back into hit-testing. -->
|
||||
<div class="text-sm leading-tight fb-selectable pointer-events-auto">
|
||||
<div><span id="hud-artist" class="text-gray-300"></span> — <span id="hud-title" class="text-white font-semibold"></span></div>
|
||||
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
|
||||
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
|
||||
@@ -846,9 +856,12 @@
|
||||
<div id="v3-live-performance-state" class="v3-live-performance-state" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div id="v3-upnext" class="v3-upnext hidden">
|
||||
<span class="text-gray-400">Up Next:</span>
|
||||
<span id="v3-upnext-name" class="v3-upnext-name"></span>
|
||||
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
|
||||
<div class="v3-upnext-row">
|
||||
<span class="text-gray-400">Up Next:</span>
|
||||
<span id="v3-upnext-name" class="v3-upnext-name"></span>
|
||||
<span id="v3-upnext-eta" class="text-gray-400 text-xs"></span>
|
||||
</div>
|
||||
<div class="v3-upnext-bar"><div id="v3-upnext-bar-fill" class="v3-upnext-bar-fill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -914,6 +927,15 @@
|
||||
<option value="0.5">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="v3-pop-row">
|
||||
<span class="v3-pop-label" id="min-scale-label">Min res</span>
|
||||
<select id="min-scale-select" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="v3-pop-select" aria-labelledby="min-scale-label" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
|
||||
<option value="0.25">25%</option>
|
||||
<option value="0.5">50%</option>
|
||||
<option value="0.75">75%</option>
|
||||
<option value="1">Full</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="v3-pop-row">
|
||||
<span class="v3-pop-label" id="scoreboard-label">Scoreboard</span>
|
||||
<select id="scoreboard-select" onchange="setScoreboard(this.value)" class="v3-pop-select" aria-labelledby="scoreboard-label" title="Highway scoreboard">
|
||||
|
||||
+88
-11
@@ -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,25 +181,68 @@
|
||||
}
|
||||
|
||||
// ── 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';
|
||||
pill.classList.remove('hidden');
|
||||
// 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).
|
||||
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;
|
||||
else break;
|
||||
}
|
||||
const span = next.time - prevT;
|
||||
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
|
||||
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 + ')';
|
||||
}
|
||||
}
|
||||
_upnextSetHidden(els, false);
|
||||
}
|
||||
|
||||
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
|
||||
@@ -217,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) {
|
||||
@@ -236,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);
|
||||
@@ -250,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();
|
||||
@@ -268,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() {
|
||||
|
||||
+13
-2
@@ -139,19 +139,30 @@
|
||||
'<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>' +
|
||||
'<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>' : '') +
|
||||
(isSystem ? '' :
|
||||
'<div class="flex gap-2 shrink-0">' +
|
||||
'<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>' : '') +
|
||||
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
|
||||
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
|
||||
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden"></div>') +
|
||||
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(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>') +
|
||||
'</div>';
|
||||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
||||
// 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.
|
||||
root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
|
||||
const files = (pl.songs || []).map((s) => s.filename).filter(Boolean);
|
||||
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]));
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
||||
|
||||
+48
-10
@@ -330,9 +330,16 @@
|
||||
const editing = !!opts.editing;
|
||||
document.getElementById('v3-onboarding')?.remove();
|
||||
|
||||
// The amp-sim opt-in step (step 5) only exists in the desktop app — the
|
||||
// pure-web build has no native amp sims to monitor through, so the step
|
||||
// is skipped there (calibration is the last step at index 5 on web, 6 on
|
||||
// desktop). See feedBack-desktop#46.
|
||||
const isDesktop = !!window.feedBackDesktop;
|
||||
const lastStep = isDesktop ? 6 : 5;
|
||||
|
||||
const stepDots = editing ? '' :
|
||||
'<div class="flex justify-center gap-1.5 mt-3" id="v3-ob-dots">' +
|
||||
[1, 2, 3, 4, 5].map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
|
||||
Array.from({ length: lastStep }, (_, i) => i + 1).map((n) => '<span data-dot="' + n + '" class="w-2 h-2 rounded-full bg-fb-border"></span>').join('') +
|
||||
'</div>';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
@@ -380,8 +387,20 @@
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Pick your instrument path(s)</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.</p>' +
|
||||
'<div id="v3-ob-paths" class="grid grid-cols-3 gap-2"></div></div>' +
|
||||
// Step 5 — calibration offer (first-run only).
|
||||
// Step 5 — amp-sim opt-in (DESKTOP ONLY; default OFF / own-rig first).
|
||||
// Hidden div is always present in the DOM; setStep only navigates to
|
||||
// it on desktop. See feedBack-desktop#46.
|
||||
'<div id="v3-ob-step5" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">How do you want to hear yourself?</label>' +
|
||||
'<p class="text-sm text-fb-textDim mb-3">fee[dB]ack can run your guitar through built-in <span class="text-fb-text">amp simulations</span> (NAM / IRs / plugins) so you hear a processed tone. If you already play through your <span class="text-fb-text">own amp or rig</span>, leave this off — you’ll get clean, silent monitoring and never an idle buzz.</p>' +
|
||||
'<label class="flex items-start gap-3 cursor-pointer rounded-lg border border-fb-border/50 bg-fb-bg/40 p-3">' +
|
||||
'<input type="checkbox" id="v3-ob-ampsims" class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-fb-primary focus:ring-fb-primary">' +
|
||||
'<span class="text-sm text-fb-text">Use in-app amp simulations' +
|
||||
'<span class="block text-xs text-fb-textDim mt-1">Loads your saved tone chain for monitoring. You can change this any time in the desktop Audio settings.</span></span>' +
|
||||
'</label>' +
|
||||
'<p class="text-xs text-fb-textDim mt-2">Leave it unticked if you monitor through your own gear. This is off by default.</p></div>' +
|
||||
// Step 6 — calibration offer (first-run only).
|
||||
'<div id="v3-ob-step6" class="hidden">' +
|
||||
'<label class="block text-xs uppercase tracking-wider text-fb-textDim mb-2">Calibration challenge</label>' +
|
||||
'<p class="text-sm text-fb-textDim">Prove your setup: play the <span class="text-fb-text">fee[dB]ack Diagnostic</span> with note detection and finish at <span class="text-fb-text font-semibold">100% accuracy</span> to reach <span class="text-fb-text font-semibold">Mastery Rank 1</span>.</p>' +
|
||||
'<p class="text-sm text-fb-textDim mt-2">Not ready? Skip it and you’ll start at Rank 1 anyway — you can still play it later from the Progress screen.</p></div>' +
|
||||
@@ -441,7 +460,7 @@
|
||||
function setStep(n) {
|
||||
step = n;
|
||||
errEl.classList.add('hidden');
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n);
|
||||
}
|
||||
overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => {
|
||||
@@ -454,12 +473,13 @@
|
||||
: n === 2 ? 'Point us at your songs'
|
||||
: n === 3 ? 'Feats of Power (optional)'
|
||||
: n === 4 ? 'Choose your instrument paths'
|
||||
: n === 5 ? 'How do you want to monitor?'
|
||||
: 'One last thing — calibrate your setup';
|
||||
}
|
||||
submit.textContent = n === 5 ? 'Play it now' : 'Next';
|
||||
submit.textContent = n === 6 ? 'Play it now' : 'Next';
|
||||
// Skip is offered on the song-directory step (configure later) and
|
||||
// the calibration challenge.
|
||||
skipBtn.classList.toggle('hidden', !(n === 2 || n === 5));
|
||||
// the calibration challenge (the last step).
|
||||
skipBtn.classList.toggle('hidden', !(n === 2 || n === 6));
|
||||
refreshSubmit();
|
||||
}
|
||||
|
||||
@@ -695,11 +715,29 @@
|
||||
// New step: input-device selection + calibration, between
|
||||
// path selection and the note-detect calibration challenge.
|
||||
await runInputSetup(selectedPaths);
|
||||
setStep(5);
|
||||
setStep(isDesktop ? 5 : 6);
|
||||
} catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
// Step 5 — "Play it now": leave calibration pending (it completes
|
||||
if (step === 5) {
|
||||
// Step 5 (desktop only) — persist the amp-sim opt-in (default OFF
|
||||
// / own-rig). Best-effort: a failed write must not block onboarding;
|
||||
// it's settable later from the desktop Audio settings.
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const ampEl = overlay.querySelector('#v3-ob-ampsims');
|
||||
const useAmpSims = !!(ampEl && ampEl.checked);
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ use_amp_sims: useAmpSims }),
|
||||
});
|
||||
} catch (e) { /* best-effort — settable later */ }
|
||||
setStep(6);
|
||||
} finally { refreshSubmit(); }
|
||||
return;
|
||||
}
|
||||
// Step 6 — "Play it now": leave calibration pending (it completes
|
||||
// through the normal scored-stats path) and launch the diagnostic.
|
||||
const target = diagnosticFilename;
|
||||
await finish({ launchingSong: !!target });
|
||||
@@ -713,8 +751,8 @@
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
// Step 5 — skip: Mastery Rank 1 immediately, calibration stays
|
||||
// replayable from the Progress screen.
|
||||
// Calibration step (last) — skip: Mastery Rank 1 immediately,
|
||||
// calibration stays replayable from the Progress screen.
|
||||
skipBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/progression/onboarding', {
|
||||
|
||||
+1013
-117
File diff suppressed because it is too large
Load Diff
+209
-2
@@ -4,6 +4,62 @@
|
||||
* `fb` palette in tailwind.config.js.
|
||||
*/
|
||||
|
||||
/* ── 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
|
||||
useful — so default the interface to non-selectable, then opt *content* back
|
||||
in. v3-only: this sheet loads only on /v3 (v2 is unchanged). The panel's
|
||||
guardrails are baked in:
|
||||
- NEVER a `* { user-select:none }` rule — it breaks input carets / IME
|
||||
composition on WebKit (bug 82692); we scope to `html` and re-enable below.
|
||||
- This is cosmetic only; it protects nothing (DevTools defeats it) and must
|
||||
never be used to "lock" copy-worthy text away (a11y: keep errors, IDs,
|
||||
paths, versions, metadata, lyrics selectable — incl. in modals/toasts). */
|
||||
html { -webkit-user-select: none; user-select: none; }
|
||||
|
||||
/* Form fields are ALWAYS selectable/editable — protects the caret + IME
|
||||
(including CJK / dead-key composition). The default must never swallow typing.
|
||||
`.fb-selectable *` forces descendants so a child element's own non-select
|
||||
can't strand copy-worthy text inside a content island. */
|
||||
input, textarea, select,
|
||||
[contenteditable]:not([contenteditable="false"]),
|
||||
[contenteditable]:not([contenteditable="false"]) * {
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
|
||||
/* Plugin screens are content surfaces (editor, tabview, lyrics, theory, chord
|
||||
text, …). Re-enable their mounted subtree by INHERITANCE (no `*`) so the host
|
||||
policy can't silently make a plugin's copyable text un-selectable — including
|
||||
community / out-of-tree plugins that never adopt `.fb-selectable`. A plugin
|
||||
that wants its own chrome non-selectable still wins via its own element rule
|
||||
(which this inherited value doesn't override). */
|
||||
.screen[id^="plugin-"] { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* Core read-only content opts back in by CONTAINER (lower-drift than tagging
|
||||
each value — a new setting added later inherits "selectable" for free):
|
||||
the Settings panel (values, paths, device names, version, diagnostics,
|
||||
About) and the now-playing song metadata (both tagged `.fb-selectable`).
|
||||
Plugins re-enable their own copyable regions with this same class
|
||||
(documented in CLAUDE.md).
|
||||
|
||||
The focused, transient surfaces below ALWAYS carry copy-worthy text (errors,
|
||||
IDs, file paths, device/version strings) per the a11y guardrail, so they're
|
||||
blanket-opted-in by selector rather than hand-tagged — they're single focused
|
||||
panels, not dense card lists, so re-enabling selection there can't recreate
|
||||
the across-cards marquee mess the policy prevents:
|
||||
- modals / dialogs: `.feedBack-modal`, `[role="dialog"]` (confirm, edit-meta,
|
||||
retune result/error, calibration, filter drawer);
|
||||
- toasts: `#fb-notify-stack`, `#v3-fb-toast`;
|
||||
- the library scan banner (`#scan-banner` — shows the current file path).
|
||||
(Dense card lists — the library grid, dashboard, profile — are intentionally
|
||||
left non-selectable; copy their text from the now-playing HUD / Settings.) */
|
||||
.fb-selectable, .fb-selectable *,
|
||||
.feedBack-modal, .feedBack-modal *,
|
||||
[role="dialog"], [role="dialog"] *,
|
||||
#fb-notify-stack, #fb-notify-stack *,
|
||||
#v3-fb-toast, #v3-fb-toast *,
|
||||
#scan-banner, #scan-banner * { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */
|
||||
#tuner-toggle-btn { display: none !important; }
|
||||
|
||||
@@ -284,8 +340,9 @@
|
||||
/* — Up Next pill (top-right, persistent) — */
|
||||
#player-hud .v3-upnext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: .35rem;
|
||||
padding: .45rem .9rem;
|
||||
border-radius: .75rem;
|
||||
background: rgba(15, 23, 42, .7);
|
||||
@@ -295,6 +352,31 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
#player-hud .v3-upnext.hidden { display: none; }
|
||||
/* Text row keeps the original inline layout untouched. */
|
||||
#player-hud .v3-upnext .v3-upnext-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
/* Progress bar under the text — fills as the current section elapses. */
|
||||
#player-hud .v3-upnext .v3-upnext-bar {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, .25);
|
||||
overflow: hidden;
|
||||
}
|
||||
#player-hud .v3-upnext .v3-upnext-bar-fill {
|
||||
height: 100%;
|
||||
/* 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: transform .12s linear;
|
||||
}
|
||||
|
||||
/* — Live performance HUD (top-right, read-only) — */
|
||||
.v3-live-performance-hud {
|
||||
@@ -1117,3 +1199,128 @@ html.fb-immersive #v3-main > .screen.active {
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* — A–Z jump rail (v3 Songs grid; static/v3/songs.js) — */
|
||||
/* Fixed to the right edge next to the scroller's scrollbar; vertically
|
||||
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
|
||||
.v3-azrail {
|
||||
position: fixed;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-height: 84vh;
|
||||
padding: 4px 1px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none; /* let a drag scrub the rail without scrolling the page */
|
||||
}
|
||||
.v3-azrail-letter {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #94a3b8; /* fb-textDim */
|
||||
font-size: .62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
padding: 1px 4px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.v3-azrail-letter:hover:not([disabled]),
|
||||
.v3-azrail-letter.is-active {
|
||||
color: #0ea5e9; /* fb-primary */
|
||||
}
|
||||
.v3-azrail-letter:focus-visible {
|
||||
outline: 2px solid #38bdf8; /* fb-primaryHi */
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.v3-azrail-letter[disabled] {
|
||||
color: rgba(148, 163, 184, .28);
|
||||
cursor: default;
|
||||
}
|
||||
/* Drag indicator bubble (Android fast-scroll pattern). */
|
||||
.v3-azbubble {
|
||||
position: fixed;
|
||||
right: 2.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 26;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: .7rem;
|
||||
background: #0ea5e9; /* fb-primary */
|
||||
color: #f8fafc; /* fb-text */
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, .45);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3-azrail.hidden,
|
||||
.v3-azbubble.hidden { display: none; }
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
|
||||
#v3-lib-home.hidden { display: none; }
|
||||
.v3-rep-meter { max-width: 30rem; }
|
||||
.v3-rep-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, .22); /* fb-textDim @ low alpha */
|
||||
overflow: hidden;
|
||||
}
|
||||
.v3-rep-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: #0ea5e9; /* fb-primary */
|
||||
transition: width .4s ease;
|
||||
}
|
||||
/* Horizontal, scroll-snapping shelf of fixed-width cards. */
|
||||
.v3-kp-row {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
padding-bottom: 6px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.v3-kp-card {
|
||||
flex: 0 0 8.5rem;
|
||||
width: 8.5rem;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
/* — Windowed (virtualized) Songs grid (#636 item 3 stage 2) — */
|
||||
/* The grid is absolutely positioned inside #v3-songs-gridsizer, whose height is
|
||||
set to the FULL library (ceil(total/cols)*rowH) so the scrollbar reflects the
|
||||
whole library while only the visible window's cards are in the DOM. The inline
|
||||
`top` (set by renderWindow) offsets the window to the first visible row. */
|
||||
.v3-grid-window {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
/* The arrangement-chip row is rendered on EVERY card (even when empty) at a fixed
|
||||
single-line height — uniform card height is what makes the window's
|
||||
absolute-position math exact. Extra chips are clipped rather than wrapping. */
|
||||
.v3-card-chips {
|
||||
height: 1.5rem;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
/* Skeleton placeholder shown only if a window's fetch hasn't landed; mirrors a
|
||||
real card's vertical structure so it occupies an identical row height. */
|
||||
.v3-card-skel { pointer-events: none; }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
|
||||
// stage 2). Before virtualization the grid appended every scrolled page, so for
|
||||
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
|
||||
// Now only the visible window (± overscan) is ever in the DOM while a sizer
|
||||
// element gives the scrollbar the full-library geometry.
|
||||
//
|
||||
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
|
||||
// deterministic in CI without a seeded 2000-row library: /api/library serves a
|
||||
// synthetic page from the page/after param with total 2001, and the keyset
|
||||
// cursor is mocked as the next absolute offset.
|
||||
|
||||
const TOTAL = 2001;
|
||||
const PAGE_SIZE = 24;
|
||||
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z
|
||||
// rail has real buckets and a jump has somewhere to land.
|
||||
function songAt(i: number) {
|
||||
const letter = COLS[i % 26];
|
||||
return {
|
||||
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
|
||||
title: `Song ${String(i).padStart(4, '0')}`,
|
||||
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
|
||||
album: `${letter} Album`,
|
||||
format: 'sloppak',
|
||||
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
|
||||
};
|
||||
}
|
||||
|
||||
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
|
||||
function sortLetters() {
|
||||
const m: Record<string, number> = {};
|
||||
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
|
||||
return m;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/library?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const after = url.searchParams.get('after');
|
||||
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
|
||||
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
|
||||
const songs = [];
|
||||
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
|
||||
const nextOffset = offset + size;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
songs, total: TOTAL, page: Math.floor(offset / size), size,
|
||||
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route('**/api/library/stats**', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
|
||||
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
|
||||
await page.route('**/api/library/providers', (route) => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||
await page.route('**/api/library/tuning-names**', (route) => route.fulfill({ json: { tunings: [] } }));
|
||||
await page.route('**/api/stats/best', (route) => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/stats/recent**', (route) => route.fulfill({ json: [] }));
|
||||
});
|
||||
|
||||
async function openSongs(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
await page.evaluate(() => {
|
||||
// @ts-ignore — neutralize playback so a stray click can't navigate away.
|
||||
window.playSong = () => Promise.resolve();
|
||||
// @ts-ignore
|
||||
window.showScreen('v3-songs');
|
||||
});
|
||||
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
|
||||
}
|
||||
|
||||
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
|
||||
// The count reflects the FULL library even though only a window is rendered.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
|
||||
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
|
||||
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
|
||||
expect(scrollHeight).toBeGreaterThan(20000);
|
||||
|
||||
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
|
||||
const CAP = 150;
|
||||
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
for (let s = 0; s < 50; s++) {
|
||||
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
|
||||
await page.waitForTimeout(60);
|
||||
const n = await page.locator('#v3-songs-grid [data-fn]').count();
|
||||
maxNodes = Math.max(maxNodes, n);
|
||||
expect(n).toBeLessThanOrEqual(CAP);
|
||||
}
|
||||
// Sanity: we actually rendered a window (not zero), and stayed well under the
|
||||
// unbounded 2001 the old append-everything grid would have produced.
|
||||
expect(maxNodes).toBeGreaterThan(0);
|
||||
expect(maxNodes).toBeLessThanOrEqual(CAP);
|
||||
|
||||
// The count is still correct after scrolling to the end.
|
||||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||||
});
|
||||
|
||||
test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => {
|
||||
await openSongs(page);
|
||||
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
|
||||
|
||||
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
|
||||
await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.v3-azrail-letter')]
|
||||
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
|
||||
if (!b) throw new Error('no M rail letter'); b.click();
|
||||
});
|
||||
|
||||
// After the jump+window render, an 'M' card is present near the top of the
|
||||
// viewport (the jump is O(1) via sort_letters, not a full page-through).
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const main = document.getElementById('v3-main')!;
|
||||
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
|
||||
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
|
||||
});
|
||||
}), { timeout: 5000 }).toBe(true);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,6 +130,59 @@ test('measure-start cache is invalidated on song change', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Fret-row fit guard ──────────────────────────────────────────────────────
|
||||
// Keeps the heat-coloured fret-number row from clipping off the bottom edge
|
||||
// when a tight, centred zoom (worst mid-neck) drops it below the lower-third
|
||||
// framing. camUpdate dollies the camera back via a capped, hysteretic boost.
|
||||
|
||||
test('fret-row fit guard constants are defined', () => {
|
||||
for (const name of [
|
||||
'FRET_ROW_FIT_NDC_MIN', 'FRET_ROW_FIT_DEADBAND', 'FRET_ROW_FIT_BOOST_MAX',
|
||||
]) {
|
||||
assert.match(src, new RegExp('const\\s+' + name + '\\s*='),
|
||||
`${name} must be declared as a fit-guard constant`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the curDist lerp target applies the fit-guard dolly boost', () => {
|
||||
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
|
||||
assert.match(
|
||||
src,
|
||||
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
|
||||
'curDist must lerp toward tgtDist * _fretRowFitBoost',
|
||||
);
|
||||
});
|
||||
|
||||
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
|
||||
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
|
||||
'the guard must probe the same row band the fret-number row is drawn at',
|
||||
);
|
||||
// Prompt pull-back when below the min, capped at BOOST_MAX.
|
||||
assert.match(
|
||||
src,
|
||||
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
|
||||
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
|
||||
);
|
||||
// Lazy relax only once past the deadband, floored at 1.
|
||||
assert.match(
|
||||
src,
|
||||
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
|
||||
'past the deadband the boost relaxes back toward 1',
|
||||
);
|
||||
});
|
||||
|
||||
test('the fit guard yields to the free-cam (Camera Director)', () => {
|
||||
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
|
||||
'with the free-cam enabled the guard must drop any auto dolly back to 1',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Debug hook stayed removed ───────────────────────────────────────────────
|
||||
|
||||
test('temporary camera debug hook is not present', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Pins the wide-pane horizontal-FOV-hold ("Hor+") framing in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// What it guards: ultra-wide panes (top/bottom 2-player split → full-width /
|
||||
// half-height → ~32:9) used to render the neck as a thin central sliver because
|
||||
// THREE's PerspectiveCamera fov is VERTICAL and was locked at 70°, ballooning
|
||||
// the horizontal cone past 130°. The fix lets camUpdate lower the effective
|
||||
// vertical fov as the pane widens (holding the horizontal cone ~constant) so the
|
||||
// neck fills the pane. It is gated behind window.__h3dAspectTune (default off →
|
||||
// byte-for-byte the prior behaviour) for live A/B comparison.
|
||||
//
|
||||
// A refactor that re-hardcodes the camera fov, drops the change-guarded cam.fov
|
||||
// write, stops caching the pane aspect, or removes the no-op-at-startAspect
|
||||
// guarantee would silently regress the feature (or worse, change normal-pane
|
||||
// framing). These are source-level pins — same strategy as the other
|
||||
// tests/js/ files (no DOM / WebGL in CI).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('BASE_VFOV is a named constant (not a literal in the camera ctor)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+BASE_VFOV\s*=\s*70\s*;/,
|
||||
'BASE_VFOV must be declared as a constant',
|
||||
);
|
||||
});
|
||||
|
||||
test('the camera is constructed with BASE_VFOV, not a bare 70', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/new\s+T\.PerspectiveCamera\(\s*BASE_VFOV\s*,/,
|
||||
'PerspectiveCamera must take BASE_VFOV as its vertical fov',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Hor+ start-aspect and min-vfov defaults exist', () => {
|
||||
assert.match(src, /const\s+HORPLUS_START_ASPECT\s*=\s*16\s*\/\s*9\s*;/,
|
||||
'HORPLUS_START_ASPECT must default to 16/9 (no-op at/under the reference aspect)');
|
||||
assert.match(src, /const\s+HORPLUS_MIN_VFOV\s*=\s*\d+\s*;/,
|
||||
'HORPLUS_MIN_VFOV floor must be declared');
|
||||
});
|
||||
|
||||
// ── effectiveVfov: no-op guarantees ──────────────────────────────────────────
|
||||
|
||||
test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
|
||||
// The disabled / malformed-input guard returns `base` before any Hor+ math,
|
||||
// so normal panes are unaffected when __h3dAspectTune is missing or off.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
|
||||
'effectiveVfov must short-circuit to the base fov when disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('effectiveVfov is a no-op at/under the start aspect', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
|
||||
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
|
||||
);
|
||||
});
|
||||
|
||||
// ── shipped defaults: off + coherent ─────────────────────────────────────────
|
||||
// The "default off → byte-for-byte prior behaviour" contract only holds if the
|
||||
// shipped _ASPECT_DEFAULTS actually ship disabled with a base that matches the
|
||||
// camera's constructed fov. A previous revision shipped enabled:true with
|
||||
// baseVfov:30 (and blend:0), which forced every pane's fov to 30/36 and
|
||||
// silently re-framed normal single-player panes. These pin against that.
|
||||
|
||||
test('_ASPECT_DEFAULTS ships disabled (no-op out of the box)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\benabled\s*:\s*false\b/,
|
||||
'_ASPECT_DEFAULTS.enabled must default to false so the feature is opt-in',
|
||||
);
|
||||
});
|
||||
|
||||
test('the default base fov matches BASE_VFOV (enabling is still a no-op on normal panes)', () => {
|
||||
// baseVfov === BASE_VFOV means even with the feature ON, a <=startAspect pane
|
||||
// returns the unchanged 70° — the effect is confined to genuinely wide panes.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bbaseVfov\s*:\s*BASE_VFOV\b/,
|
||||
'_ASPECT_DEFAULTS.baseVfov must default to BASE_VFOV, not a divergent literal',
|
||||
);
|
||||
});
|
||||
|
||||
test('the default blend engages the hold and the floor sits below the base', () => {
|
||||
// blend:1 means turning the feature on actually holds the horizontal cone
|
||||
// (blend:0 would collapse effectiveVfov back to base = feature inert), and
|
||||
// minVfovDeg:HORPLUS_MIN_VFOV keeps the floor below baseVfov (a real floor,
|
||||
// not one that clamps the base upward).
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bblend\s*:\s*1\b/,
|
||||
'_ASPECT_DEFAULTS.blend must default to 1 so the Hor+ hold actually applies when enabled',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_ASPECT_DEFAULTS\s*=\s*\{[\s\S]*?\bminVfovDeg\s*:\s*HORPLUS_MIN_VFOV\b/,
|
||||
'_ASPECT_DEFAULTS.minVfovDeg must default to HORPLUS_MIN_VFOV (a floor below baseVfov)',
|
||||
);
|
||||
});
|
||||
|
||||
// ── camUpdate: change-guarded fov write + cached aspect ───────────────────────
|
||||
|
||||
test('applySize caches the pane aspect for camUpdate', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/_paneAspect\s*=\s*cam\.aspect\s*;/,
|
||||
'applySize must cache cam.aspect into _paneAspect',
|
||||
);
|
||||
});
|
||||
|
||||
test('camUpdate resolves a per-pane tune and respects splitOnly', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
|
||||
'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly',
|
||||
);
|
||||
});
|
||||
|
||||
test('the tune bridge seeds from localStorage (persisted sessions apply on load)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectTune\s*\(\)[\s\S]*?localStorage\.getItem\(\s*_ASPECT_LS\s*\)/,
|
||||
'_aspectTune() must seed the bridge from localStorage',
|
||||
);
|
||||
});
|
||||
|
||||
test('a floating tuner panel is built and can be shown/hidden', () => {
|
||||
assert.match(src, /function\s+_ensureAspectPanel\s*\(\)/,
|
||||
'_ensureAspectPanel() must exist to build the live panel');
|
||||
assert.match(src, /function\s+_setAspectPanelVisible\s*\(/,
|
||||
'_setAspectPanelVisible() must show/hide the panel');
|
||||
});
|
||||
|
||||
// ── Per-pane targeting ────────────────────────────────────────────────────────
|
||||
|
||||
test('the tune resolves per pane with a sparse override map', () => {
|
||||
// _resolveTuneFor overlays a pane's __panels[key] overrides onto the base so
|
||||
// one split pane can be framed independently of the others.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_resolveTuneFor\s*\(\s*paneKey\s*\)[\s\S]*?base\.__panels\s*&&\s*base\.__panels\[\s*paneKey\s*\]/,
|
||||
'_resolveTuneFor must overlay per-pane overrides from base.__panels',
|
||||
);
|
||||
});
|
||||
|
||||
test('panel writes route to the selected target (base or a pane override)', () => {
|
||||
// _aspectWriteVal writes to the base when target is empty, else into the
|
||||
// pane override sub-object; camUpdate consumes it via _resolveTuneFor.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectWriteVal\s*\([\s\S]*?if\s*\(\s*!_aspectEditTarget\s*\)[\s\S]*?base\.__panels\b[\s\S]*?\[\s*_aspectEditTarget\s*\]/,
|
||||
'_aspectWriteVal must target base for "all" and __panels[target] for a pane',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Target select and pane registry drive the per-pane picker', () => {
|
||||
assert.match(src, /_aspectTargetSel\s*=\s*document\.createElement\(\s*'select'\s*\)/,
|
||||
'the panel must build a Target <select>');
|
||||
assert.match(src, /function\s+_aspectRegisterPane\s*\(/,
|
||||
'_aspectRegisterPane must record live panes for the picker');
|
||||
assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/,
|
||||
'camUpdate must register its pane only while the tuner panel is open');
|
||||
});
|
||||
|
||||
test('panes are keyed by arrangement (stable across songs, no split-API dep)', () => {
|
||||
// 'arr:<name>' keys are distinct between split panes AND stable across
|
||||
// songs, without depending on the external splitscreen panel index (which
|
||||
// isn't always available). A per-instance id is the no-arrangement fallback.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/,
|
||||
'_aspectPaneKey must prefer arr:<name> and fall back to pane:<uid>',
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/,
|
||||
'camUpdate must key the pane by arrangement (with the uid fallback)',
|
||||
);
|
||||
});
|
||||
|
||||
test('arrangement-keyed overrides persist; instance-id keys stay session-only', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectPersist\s*\(\)[\s\S]*?k\.slice\(0,\s*4\)\s*===\s*'arr:'[\s\S]*?out\.__panels\s*=\s*p/,
|
||||
'_aspectPersist must persist only arr:* overrides so they carry across songs',
|
||||
);
|
||||
});
|
||||
|
||||
test('the target dropdown prunes dead panes and does not rebuild while focused', () => {
|
||||
assert.match(src, /function\s+_aspectPrunePanes\s*\(\)[\s\S]*?delete\s+reg\[k\]/,
|
||||
'_aspectPrunePanes must drop panes not seen recently');
|
||||
assert.match(src, /_aspectPrunePanes\(\)\s*;[\s\S]*?if\s*\(\s*_aspectPanesDirty\s*\)\s*_aspectBuildTargets\(\)/,
|
||||
'the readout tick must prune then rebuild only when dirty');
|
||||
assert.match(src, /function\s+_aspectBuildTargets\s*\(\)[\s\S]*?document\.activeElement\s*===\s*_aspectTargetSel[\s\S]*?return/,
|
||||
'_aspectBuildTargets must skip rebuilding while the select is focused');
|
||||
});
|
||||
|
||||
test('programmatic sync does not write back into the tune', () => {
|
||||
// _syncAspectPanel dispatches synthetic input events to refresh labels; the
|
||||
// slider handler must skip the write while syncing, else opening/switching a
|
||||
// target would populate a full override for every field.
|
||||
assert.match(src, /_aspectSyncing\s*=\s*true[\s\S]*?finally[\s\S]*?_aspectSyncing\s*=\s*false/,
|
||||
'_syncAspectPanel must set/reset the _aspectSyncing guard');
|
||||
assert.match(src, /if\s*\(\s*!_aspectSyncing\s*\)\s*_aspectWriteVal\(\s*f\.k\s*,/,
|
||||
'the slider input handler must skip the write while syncing');
|
||||
});
|
||||
|
||||
test('unchecking hfov override clears a pane override key (re-inherits base)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectClearVal\s*\(\s*k\s*\)[\s\S]*?delete\s+ov\[k\][\s\S]*?delete\s+m\[\s*_aspectEditTarget\s*\]/,
|
||||
'_aspectClearVal must delete the pane override key (and empty object)',
|
||||
);
|
||||
assert.match(src, /else\s+_aspectClearVal\(\s*'hfovDeg'\s*\)/,
|
||||
'unchecking the hfov override must call _aspectClearVal');
|
||||
});
|
||||
|
||||
test('pruning drops the matching readout slot and a dangling __last', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/delete\s+reg\[k\]\s*;[\s\S]*?delete\s+ro\[k\]\s*;\s*if\s*\(\s*ro\.__last\s*===\s*k\s*\)\s*delete\s+ro\.__last/,
|
||||
'_aspectPrunePanes must prune the readout cache alongside the registry',
|
||||
);
|
||||
});
|
||||
|
||||
test('single-pane forces the edit target back to All (no hidden pane edits)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*keys\.length\s*<=\s*1\s*\|\|\s*\(\s*_aspectEditTarget\s*&&\s*!reg\[_aspectEditTarget\]\s*\)\s*\)\s*\{\s*_aspectEditTarget\s*=\s*''/,
|
||||
'_aspectBuildTargets must reset the edit target to "" when the Target row is hidden',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolved per-pane tune is memoized and invalidated by a revision', () => {
|
||||
assert.match(src, /_aspectRev\s*\+\+/, 'a mutation revision must be bumped on persist');
|
||||
assert.match(
|
||||
src,
|
||||
/_aspectResolveCache\.get\(\s*paneKey\s*\)[\s\S]*?c\.rev\s*===\s*_aspectRev[\s\S]*?return\s+c\.obj/,
|
||||
'_resolveTuneFor must return a cached object when the revision is unchanged',
|
||||
);
|
||||
});
|
||||
|
||||
test('the pane clock falls back to Date.now so pruning keeps working', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_aspectNowMs\s*\(\)[\s\S]*?performance\.now\(\)[\s\S]*?return\s+Date\.now\(\)/,
|
||||
'_aspectNowMs must fall back to Date.now() when the Performance API is absent',
|
||||
);
|
||||
});
|
||||
|
||||
test('opening the panel prunes before the first dropdown build', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/if\s*\(\s*on\s*\)\s*\{\s*_aspectPrunePanes\(\)\s*;\s*_aspectBuildTargets\(\)/,
|
||||
'_setAspectPanelVisible must prune stale panes before building the dropdown',
|
||||
);
|
||||
});
|
||||
|
||||
test('Reset on All restores defaults exactly (no forced enabled)', () => {
|
||||
// Panel visibility is independent of the enabled flag now, so Reset must not
|
||||
// force enabled true — it should restore _ASPECT_DEFAULTS verbatim.
|
||||
assert.doesNotMatch(src, /Object\.keys\(_ASPECT_DEFAULTS\)[\s\S]*?base\.enabled\s*=\s*true/,
|
||||
'Reset must not override the default enabled state');
|
||||
});
|
||||
|
||||
test('the panel has a dismiss (close) control', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/close\.textContent\s*=\s*'×'[\s\S]*?_setAspectPanelVisible\(\s*false\s*\)/,
|
||||
'the panel header must have a × button that hides the panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('camUpdate only writes cam.fov when it actually changes', () => {
|
||||
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady
|
||||
// pane and keeps the disabled path free.
|
||||
assert.match(
|
||||
src,
|
||||
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
|
||||
'camUpdate must guard the cam.fov write behind a change check',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Shortcut (open/close) + lifecycle reset ───────────────────────────────────
|
||||
|
||||
test('the shortcut opens/closes the tuner panel', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/registerShortcut\(\{[\s\S]*?_toggleAspectPanel\(\)/,
|
||||
'a registerShortcut handler must toggle the tuner panel',
|
||||
);
|
||||
assert.match(src, /function\s+_toggleAspectPanel\s*\(\)/,
|
||||
'_toggleAspectPanel() must exist to reveal/dismiss the panel');
|
||||
});
|
||||
|
||||
test('destroy() resets the pane aspect and restores the base fov', () => {
|
||||
assert.match(src, /_paneAspect\s*=\s*0\s*;/,
|
||||
'destroy() must reset _paneAspect to 0');
|
||||
assert.match(
|
||||
src,
|
||||
/cam\.fov\s*!==\s*BASE_VFOV[\s\S]*?cam\.fov\s*=\s*BASE_VFOV\s*;\s*cam\.updateProjectionMatrix\(\)/,
|
||||
'destroy() must restore cam.fov to BASE_VFOV for instance reuse',
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Guards the Section Practice popover's outside-click dismiss in static/app.js
|
||||
// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call
|
||||
// e.stopPropagation() in their click handler (static/v3/player-chrome.js
|
||||
// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks
|
||||
// a different rail icon (Plugins, Audio, …) — leaving the Practice popover
|
||||
// stranded open under the newly-opened one (feedBack#638). The dismiss must bind
|
||||
// in the CAPTURE phase (runs before the target's stopPropagation can swallow it).
|
||||
// Esc must stay bubble-phase so it doesn't reorder ahead of the player's
|
||||
// Escape-to-exit handling. A revert to bubble-phase should fail here.
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
|
||||
const body = m[0];
|
||||
|
||||
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
||||
assert.match(
|
||||
body,
|
||||
/addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/,
|
||||
'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s '
|
||||
+ 'stopPropagation() cannot swallow it',
|
||||
);
|
||||
});
|
||||
|
||||
test('only the click listener is capture (Escape keydown stays bubble-phase)', () => {
|
||||
// Exactly one capture binding in the installer — the click. The keydown
|
||||
// (Escape) listener must NOT be capture.
|
||||
const captureBinds = body.match(/,\s*true\s*\)/g) || [];
|
||||
assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)');
|
||||
});
|
||||
|
||||
test('the dismiss ignores clicks inside the control (no self-close)', () => {
|
||||
assert.match(body, /section-practice-control/, 'must scope to #section-practice-control');
|
||||
assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/,
|
||||
'a click inside the control (incl. the pill) must not dismiss the popover');
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// Verify the feedpak credits overlay helpers in app.js:
|
||||
// - _creditLineLabel() role → friendly "<verb> by" label
|
||||
// - showSongCreditsOverlay() builds an XSS-safe card; no-op on empty list
|
||||
// - hideSongCreditsOverlay() removes the overlay element
|
||||
//
|
||||
// Same isolation strategy as autoplay_exit.test.js — extract the functions
|
||||
// from app.js by brace-matching and run them in a vm sandbox with a fake DOM.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Minimal fake DOM element: records className, children, and textContent.
|
||||
// Setting textContent clears children (matching real DOM) so we can assert
|
||||
// names were set via textContent (not innerHTML) — the XSS-safety contract.
|
||||
function makeEl() {
|
||||
return {
|
||||
className: '',
|
||||
children: [],
|
||||
_text: '',
|
||||
set textContent(v) { this._text = String(v); this.children = []; },
|
||||
get textContent() { return this._text; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
replaceChildren() { this.children = []; },
|
||||
remove() { this.removed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function allText(node) {
|
||||
let s = node._text || '';
|
||||
for (const c of node.children) s += allText(c);
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildSandbox(currentSong) {
|
||||
const body = makeEl();
|
||||
const sandbox = {
|
||||
document: { body, createElement: () => makeEl() },
|
||||
window: { feedBack: { currentSong, off() {} } },
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const preamble = `
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
const _CREDIT_ROLE_VERBS = ${JSON.stringify({
|
||||
charter: 'Charted by', transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by', editor: 'Edited by', mixer: 'Mixed by',
|
||||
engineer: 'Engineered by', proofreader: 'Proofread by',
|
||||
})};
|
||||
`;
|
||||
vm.runInContext(
|
||||
preamble
|
||||
+ extractFunction(SRC, 'function _creditLineLabel(') + '\n'
|
||||
+ extractFunction(SRC, 'function showSongCreditsOverlay(') + '\n'
|
||||
+ extractFunction(SRC, 'function hideSongCreditsOverlay(') + '\n'
|
||||
+ 'globalThis._creditLineLabel = _creditLineLabel;'
|
||||
+ 'globalThis.showSongCreditsOverlay = showSongCreditsOverlay;'
|
||||
+ 'globalThis.hideSongCreditsOverlay = hideSongCreditsOverlay;'
|
||||
+ 'globalThis._getOverlay = () => _creditsOverlay;',
|
||||
sandbox,
|
||||
);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('_creditLineLabel maps known roles, title-cases unknown, blanks empty', () => {
|
||||
const s = buildSandbox({});
|
||||
assert.equal(s._creditLineLabel('charter'), 'Charted by');
|
||||
assert.equal(s._creditLineLabel('Editor'), 'Edited by'); // case-insensitive
|
||||
assert.equal(s._creditLineLabel('mixer'), 'Mixed by');
|
||||
assert.equal(s._creditLineLabel('luthier'), 'Luthier by'); // unknown → title-cased
|
||||
assert.equal(s._creditLineLabel(null), ''); // no role → bare name
|
||||
assert.equal(s._creditLineLabel(''), '');
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay builds a card with heading + credit lines', () => {
|
||||
const s = buildSandbox({ title: 'My Song' });
|
||||
s.showSongCreditsOverlay([
|
||||
{ name: 'Azure', role: 'charter' },
|
||||
{ name: 'Bob Lee', role: 'editor' },
|
||||
{ name: 'Solo', role: null },
|
||||
]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay, 'overlay created');
|
||||
assert.equal(overlay.className, 'song-credits-overlay');
|
||||
assert.equal(s.document.body.children.length, 1);
|
||||
const text = allText(overlay);
|
||||
assert.match(text, /My Song/); // heading is the song title
|
||||
assert.match(text, /Charted by/);
|
||||
assert.match(text, /Azure/);
|
||||
assert.match(text, /Edited by/);
|
||||
assert.match(text, /Bob Lee/);
|
||||
assert.match(text, /Solo/); // role-less entry still shows the name
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay sets names via textContent (XSS-safe)', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: '<img src=x onerror=alert(1)>', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
// The raw string survives verbatim as text — proving it was never parsed
|
||||
// as HTML (no innerHTML interpolation anywhere on the path).
|
||||
assert.match(allText(overlay), /<img src=x onerror=alert\(1\)>/);
|
||||
});
|
||||
|
||||
test('showSongCreditsOverlay is a no-op for empty / non-array input', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([]);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
s.showSongCreditsOverlay(undefined);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
assert.equal(s.document.body.children.length, 0);
|
||||
});
|
||||
|
||||
test('hideSongCreditsOverlay removes the overlay', () => {
|
||||
const s = buildSandbox({ title: 'T' });
|
||||
s.showSongCreditsOverlay([{ name: 'Azure', role: 'charter' }]);
|
||||
const overlay = s._getOverlay();
|
||||
assert.ok(overlay);
|
||||
s.hideSongCreditsOverlay();
|
||||
assert.equal(overlay.removed, true);
|
||||
assert.equal(s._getOverlay(), null);
|
||||
});
|
||||
@@ -148,6 +148,7 @@ function loadPlaySong(sandbox) {
|
||||
var _playerOriginScreen = null;
|
||||
var _pendingAutostart = false;
|
||||
function _clearAutoExit() {}
|
||||
function _clearAutoplayHold() {}
|
||||
function _resolvePlayerOrigin() { return 'home'; }
|
||||
function _recordPlaybackBridge() {}
|
||||
function _cancelCountIn() {}
|
||||
|
||||
@@ -8,6 +8,8 @@ const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
@@ -26,7 +28,14 @@ function loadTuningHelpers() {
|
||||
|
||||
const feedBackHelpers = loadTuningHelpers();
|
||||
|
||||
function createTunerSandbox() {
|
||||
function createTunerSandbox(opts) {
|
||||
// Auto-open is opt-in (default off in prod). The sandbox defaults it ON so the
|
||||
// behaviour tests exercise the feature; pass { autoOpen: false } to gate it off.
|
||||
const autoOpen = !opts || opts.autoOpen !== false;
|
||||
// The player's physical instrument for the §4 coverage check (core /api/settings).
|
||||
// Absent → the endpoint reports not-ok → coverage stays conservative (can't
|
||||
// decide → don't suppress the prompt), preserving the pre-coverage behaviour.
|
||||
const playerSettings = (opts && opts.player) || null;
|
||||
const enableCalls = [];
|
||||
let playerActive = true;
|
||||
let songInfo = null;
|
||||
@@ -38,12 +47,19 @@ function createTunerSandbox() {
|
||||
setTimeout(fn) { fn(); return 0; },
|
||||
clearTimeout() {},
|
||||
fetch(url) {
|
||||
if (String(url).includes('/config')) {
|
||||
const _u = String(url);
|
||||
if (_u.includes('/api/settings')) {
|
||||
return Promise.resolve(playerSettings
|
||||
? { ok: true, json: () => Promise.resolve(playerSettings) }
|
||||
: { ok: false, json: () => Promise.resolve({}) });
|
||||
}
|
||||
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,
|
||||
@@ -120,12 +136,8 @@ function createTunerSandbox() {
|
||||
sandbox.window.highway = {
|
||||
getSongInfo: () => songInfo,
|
||||
};
|
||||
sandbox.window._tunerUtils = {
|
||||
preferFlats: () => false,
|
||||
offsetsToFreqs: (offsets) => offsets.map((o, i) => 80 + i * 10),
|
||||
freqToMidi: () => 40,
|
||||
midiToNote: () => 'E',
|
||||
};
|
||||
// _tunerUtils comes from the REAL tuning-utils.js (loaded into the sandbox
|
||||
// below) so the §4 coverage check runs real pitch math, not stubbed values.
|
||||
sandbox.window._tunerUI = () => ({
|
||||
addButton() {},
|
||||
initUI() {},
|
||||
@@ -152,12 +164,13 @@ function createTunerSandbox() {
|
||||
});
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fs.readFileSync(TUNING_UTILS_JS, 'utf8'), sandbox);
|
||||
vm.runInContext(fs.readFileSync(TUNER_SCREEN_JS, 'utf8'), sandbox);
|
||||
|
||||
const realEnable = sandbox.window.tuner.enable.bind(sandbox.window.tuner);
|
||||
sandbox.window.tuner.enable = async () => {
|
||||
enableCalls.push(1);
|
||||
return realEnable();
|
||||
sandbox.window.tuner.enable = async (enableOpts) => {
|
||||
enableCalls.push(enableOpts || {});
|
||||
return realEnable(enableOpts);
|
||||
};
|
||||
|
||||
return sandbox;
|
||||
@@ -315,6 +328,37 @@ test('song:loading clears dismiss state for next load', async () => {
|
||||
assert.equal(sandbox.__enableCalls.length, 2);
|
||||
});
|
||||
|
||||
test('auto-open is gated off when the setting is disabled (opt-in)', async () => {
|
||||
const sandbox = createTunerSandbox({ autoOpen: false });
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, E_STANDARD); // a real tuning change, but the setting is off
|
||||
assert.equal(sandbox.__enableCalls.length, 0);
|
||||
});
|
||||
|
||||
test('auto-open enables in persist mode (passes { auto: true })', async () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, E_STANDARD);
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
assert.equal(sandbox.__enableCalls[0].auto, true);
|
||||
});
|
||||
|
||||
test('persist: an auto-opened tuner is not torn down by autoplay / stray clicks', () => {
|
||||
const screenSrc = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
|
||||
const uiSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js'), 'utf8');
|
||||
// The gate is opt-in on the server config flag.
|
||||
assert.match(screenSrc, /autoOpenOnTuningChange/);
|
||||
// enable() records whether this was an auto-open …
|
||||
assert.match(screenSrc, /_state\.autoOpened\s*=\s*auto/);
|
||||
// … the outside-click dismiss is armed only for a manual open …
|
||||
assert.match(screenSrc, /if \(!auto\)[\s\S]*?addEventListener\('click'/);
|
||||
// … and the autoplay song:play closer ignores an auto-opened tuner (the flash fix).
|
||||
assert.match(uiSrc, /state\.enabled && !state\.autoOpened/);
|
||||
});
|
||||
|
||||
test('screen.js registers song:loading and song:ready auto-open listeners at boot', () => {
|
||||
const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
|
||||
assert.match(src, /function _installAutoOpenListeners/);
|
||||
@@ -324,7 +368,400 @@ test('screen.js registers song:loading and song:ready auto-open listeners at boo
|
||||
assert.doesNotMatch(src, /restartCurrentSong/);
|
||||
});
|
||||
|
||||
// ── §4 instrument-coverage (E1.5) ──────────────────────────────────────────
|
||||
// Player physical instruments (core /api/settings shape: instrument/string_count/
|
||||
// tuning offsets/reference_pitch).
|
||||
const PLAYER_GUITAR_8_FS = { instrument: 'guitar', string_count: 8, tuning: [0, 0, 0, 0, 0, 0, 0, 0], reference_pitch: 440 }; // F# standard
|
||||
const PLAYER_GUITAR_6 = { instrument: 'guitar', string_count: 6, tuning: [0, 0, 0, 0, 0, 0], reference_pitch: 440 }; // E standard
|
||||
const SONG_7B = { filename: '7b.sloppak', arrangement: 'Lead', arrangement_index: 0, stringCount: 7, tuning: [0, 0, 0, 0, 0, 0, 0] }; // B standard 7
|
||||
const SONG_DROP_A7 = { filename: 'dropa7.sloppak', arrangement: 'Lead', arrangement_index: 0, stringCount: 7, tuning: [-2, 0, 0, 0, 0, 0, 0] }; // Drop A 7
|
||||
|
||||
test('coverage: an 8-string F# player is NOT prompted for a covered 6-/7-string standard song', async () => {
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, DROP_D); // first song → sets lastTuningKey, no open
|
||||
await ready(sandbox, E_STANDARD); // 6-E lives on the 8-string's top 6 → suppressed
|
||||
assert.equal(sandbox.__enableCalls.length, 0);
|
||||
await ready(sandbox, SONG_7B); // 7-B lives on the 8-string's top 7 → suppressed
|
||||
assert.equal(sandbox.__enableCalls.length, 0);
|
||||
});
|
||||
|
||||
test('coverage: a Drop-A 7-string song STILL prompts the 8-string F# player (dropped string absent)', async () => {
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, E_STANDARD); // first → no open
|
||||
await ready(sandbox, SONG_DROP_A7); // needs an open A1; the 8-string has F#1/B1, not A1 → prompt
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('coverage: a 6-string-standard player IS prompted for Drop D', async () => {
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_6 });
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, E_STANDARD); // first → no open
|
||||
await ready(sandbox, DROP_D); // low E must drop to D → not covered → prompt
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('coverage: a reference-pitch mismatch (A432 player vs A440 song) prompts even when the shape matches', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { ...PLAYER_GUITAR_6, reference_pitch: 432 } });
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, DROP_D); // first → no open
|
||||
await ready(sandbox, E_STANDARD); // shape matches, but the whole instrument is ~32¢ flat → prompt
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
test('coverage: covered/uncovered is decided by contiguous pitch alignment (direct)', async () => {
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
|
||||
const cover = (song) => sandbox.window._tunerAutoOpen.coveredByPlayerInstrument(song);
|
||||
assert.equal(await cover(E_STANDARD), true); // 6-E is a run inside 8-string F#
|
||||
assert.equal(await cover(SONG_7B), true); // 7-B is a run inside 8-string F#
|
||||
assert.equal(await cover(SONG_DROP_A7), false); // Drop-A's low A1 isn't an open string on it
|
||||
});
|
||||
|
||||
test('coverage: with no declared instrument it stays conservative (prompts as before)', async () => {
|
||||
const sandbox = createTunerSandbox(); // no /api/settings instrument
|
||||
sandbox.window._tunerAutoOpen.resetState();
|
||||
await ready(sandbox, CUSTOM_GUITAR);
|
||||
await ready(sandbox, E_STANDARD);
|
||||
assert.equal(sandbox.__enableCalls.length, 1);
|
||||
});
|
||||
|
||||
// ── §4 coverage report + badge cue (E1.6) ──────────────────────────────────
|
||||
test('coverage report names the string(s) to retune', async () => {
|
||||
// Note: report objects come from the vm sandbox realm, so compare fields, not
|
||||
// deepStrictEqual (which checks prototype identity across realms).
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
|
||||
const rep = (s) => sandbox.window._tunerAutoOpen.coverageReport(s);
|
||||
const covered = await rep(E_STANDARD);
|
||||
assert.equal(covered.covered, true);
|
||||
assert.equal(covered.retune.length, 0);
|
||||
assert.equal(covered.reference, false);
|
||||
const dropA = await rep(SONG_DROP_A7);
|
||||
assert.equal(dropA.covered, false);
|
||||
assert.equal(dropA.retune.length, 1);
|
||||
assert.equal(dropA.retune[0].from, 'B'); // the user's exact case → "retune B → A"
|
||||
assert.equal(dropA.retune[0].to, 'A');
|
||||
});
|
||||
|
||||
test('coverage report flags a whole-instrument reference mismatch', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { ...PLAYER_GUITAR_6, reference_pitch: 432 } });
|
||||
const rep = await sandbox.window._tunerAutoOpen.coverageReport(E_STANDARD);
|
||||
assert.equal(rep.covered, false);
|
||||
assert.equal(rep.reference, true);
|
||||
});
|
||||
|
||||
test('the tuner badge surfaces a passive coverage cue (badges.js)', () => {
|
||||
const badgesSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'v3', 'badges.js'), 'utf8');
|
||||
// Recomputes via the tuner plugin's coverageReport on song:ready …
|
||||
assert.match(badgesSrc, /api\.coverageReport/);
|
||||
assert.match(badgesSrc, /sm\.on\('song:ready'/);
|
||||
// … and shows an advisory ring + tooltip naming the retune (it never auto-opens).
|
||||
assert.match(badgesSrc, /function _applyCoverageCue/);
|
||||
assert.match(badgesSrc, /report\.retune/);
|
||||
assert.match(badgesSrc, /boxShadow/);
|
||||
});
|
||||
|
||||
// ── Autoplay gate (E2) ─────────────────────────────────────────────────────
|
||||
test('gate: claims an autoplay hold on song:loading and releases it on dismiss', async () => {
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_6 });
|
||||
let holds = 0, releases = 0;
|
||||
sandbox.window.feedBack.holdAutoplay = () => { holds++; return () => { releases++; }; };
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
await ready(sandbox, DROP_D); // loads config (feature on)
|
||||
api.resetState();
|
||||
api.onSongLoading(); // song:loading → claim the gate
|
||||
assert.equal(holds, 1);
|
||||
assert.equal(releases, 0);
|
||||
sandbox.window.tuner.disable(); // dismiss → release (playback proceeds)
|
||||
assert.equal(releases, 1);
|
||||
});
|
||||
|
||||
test('gate: does not claim a hold when the feature is off', async () => {
|
||||
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_6, autoOpen: false });
|
||||
let holds = 0;
|
||||
sandbox.window.feedBack.holdAutoplay = () => { holds++; return () => {}; };
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
await ready(sandbox, DROP_D); // loads config (feature OFF)
|
||||
api.onSongLoading();
|
||||
assert.equal(holds, 0);
|
||||
});
|
||||
|
||||
test('the autoplay gate is a generic core hook with a fail-open backstop (app.js)', () => {
|
||||
const appSrc = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.match(appSrc, /window\.feedBack\.holdAutoplay = function/);
|
||||
assert.match(appSrc, /AUTOPLAY_HOLD_BACKSTOP_MS/); // fail-open: never strand the song
|
||||
assert.match(appSrc, /if \(_autoplayHeld\) \{ _autoplayStart = start;/); // a gated start is stashed
|
||||
assert.match(appSrc, /_clearAutoplayHold\(\);[\s\S]{0,160}emit\('song:loading'/); // reset before plugins re-claim
|
||||
// Per-hold identity: a stale release from an earlier hold must not clear a later one.
|
||||
assert.match(appSrc, /token !== _autoplayHoldToken/);
|
||||
// settle(): a committed holder can cancel the fail-open backstop (no timed release).
|
||||
assert.match(appSrc, /release\.settle = function/);
|
||||
// The hook is generic — app.js still doesn't reference the tuner's internals.
|
||||
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);
|
||||
});
|
||||
|
||||
test('the tuner gates playback via holdAutoplay (screen.js)', () => {
|
||||
const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
|
||||
assert.match(src, /window\.feedBack\.holdAutoplay\(\)/); // claimed on song:loading
|
||||
assert.match(src, /_gateClaimed = true/); // kept when the tuner opens
|
||||
assert.match(src, /if \(!_gateClaimed\) _releaseGate\(\)/); // released when we don't open
|
||||
assert.match(src, /_releaseGate\(\);[\s\S]{0,80}dismissing a gated/); // released on dismiss
|
||||
// Once open, the tuner settles the hold so the 12s backstop can't start playback
|
||||
// while the player is still tuning.
|
||||
assert.match(src, /_autoplayRelease\.settle\(\)/);
|
||||
// The async song:ready handler is generation-guarded so it can't release a NEWER
|
||||
// song's gate after its await.
|
||||
assert.match(src, /_onAutoOpenSongReady = async \(\) => \{[\s\S]{0,320}myGen !== _autoOpenGeneration\) return;[\s\S]{0,120}_releaseGate/);
|
||||
});
|
||||
|
||||
test('gate escape hatch: auto-open offers "Back to library" (+ Esc) and hides the × (ui.js/screen.js)', () => {
|
||||
const ui = fs.readFileSync(TUNER_UI_JS, 'utf8');
|
||||
assert.match(ui, /Back to library/); // an explicit way out, not only play-now
|
||||
assert.match(ui, /requestExitSong/); // reuses the standard song-exit path (mirrors Esc)
|
||||
assert.match(ui, /state\.backBtn =/);
|
||||
assert.match(ui, /state\.closeBtn =/); // × captured so it can be toggled
|
||||
|
||||
const screen = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
|
||||
assert.match(screen, /_state\.backBtn[\s\S]{0,60}toggle\('hidden', !auto\)/); // shown on auto-open
|
||||
assert.match(screen, /_state\.closeBtn[\s\S]{0,60}toggle\('hidden', !!auto\)/); // × hidden on auto-open
|
||||
});
|
||||
|
||||
test('auto-open does not require app.js changes', () => {
|
||||
const appSrc = fs.readFileSync(APP_JS, 'utf8');
|
||||
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);
|
||||
});
|
||||
|
||||
// ── PR 3: per-instrument live working tuning (the both-directions fix) ──────
|
||||
test('coverage reads the live per-instrument working tuning, so it prompts BOTH directions', async () => {
|
||||
// GUITAR-6 selected; the player's LIVE working tuning is Drop-D (they retuned).
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
sandbox.window.feedBack.workingTuning = {
|
||||
get: () => ({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6, instrument: 'guitar', referencePitch: 440 }),
|
||||
set() {},
|
||||
};
|
||||
const rep = (s) => sandbox.window._tunerAutoOpen.coverageReport(s);
|
||||
// The Drop-D song now MATCHES the live tuning → covered (no prompt).
|
||||
assert.equal((await rep(DROP_D)).covered, true);
|
||||
// An E-standard song NO LONGER matches (the player is in Drop-D) → not covered →
|
||||
// prompts to tune the low string back UP to E. The old static-profile logic missed
|
||||
// this "coming back" direction entirely.
|
||||
const estd = await rep(E_STANDARD);
|
||||
assert.equal(estd.covered, false);
|
||||
assert.equal(estd.retune.length, 1);
|
||||
assert.equal(estd.retune[0].from, 'D'); // player low string is D…
|
||||
assert.equal(estd.retune[0].to, 'E'); // …song wants E → "tune D → E" (up)
|
||||
});
|
||||
|
||||
// Wire a set-capturing workingTuning stub, then run a coverage read so the tuner caches
|
||||
// the selected-instrument identity (publish is synchronous and writes to that cached
|
||||
// slot — an auto-open always runs coverage first, so this mirrors real ordering).
|
||||
async function _primeSets(sandbox, get = () => ({ offsets: null })) {
|
||||
const sets = [];
|
||||
sandbox.window.feedBack.workingTuning = { get, set: (state, opts) => sets.push({ state, opts }) };
|
||||
await sandbox.window._tunerAutoOpen.playerTuning();
|
||||
return sets;
|
||||
}
|
||||
|
||||
test('clearing the auto-opened tuner publishes the song tuning to the right instrument slot', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
const sets = await _primeSets(sandbox);
|
||||
sandbox.window._tunerAutoOpen.publishWorkingTuning(DROP_D);
|
||||
assert.equal(sets.length, 1);
|
||||
assert.deepEqual(sets[0].state.offsets, [-2, 0, 0, 0, 0, 0]); // the song's tuning
|
||||
assert.equal(sets[0].state.instrument, 'guitar');
|
||||
assert.equal(sets[0].opts.instrument, 'guitar-6'); // targets the guitar slot
|
||||
assert.equal(sets[0].opts.provenance, 'assumed'); // a guess, not mic-verified
|
||||
});
|
||||
|
||||
test('publish targets the SELECTED instrument slot, not a song-derived one (string-count mismatch)', async () => {
|
||||
// A 5-string bass is selected; the cleared song is a 4-string bass chart. The publish
|
||||
// must land in bass-5 (what coverage reads), NOT bass-4 (where it would be stranded).
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'bass', string_count: 5, tuning: 'Standard' } });
|
||||
const sets = await _primeSets(sandbox);
|
||||
sandbox.window._tunerAutoOpen.publishWorkingTuning(BASS_EADG);
|
||||
assert.equal(sets.length, 1);
|
||||
assert.equal(sets[0].opts.instrument, 'bass-5'); // the selected instrument's slot
|
||||
assert.equal(sets[0].state.instrument, 'bass');
|
||||
});
|
||||
|
||||
test('publish skips a cross-instrument chart (bass arrangement while guitar is selected)', async () => {
|
||||
// Guitar selected, but the player manually opened the Bass arrangement and cleared.
|
||||
// That is not evidence the guitar was retuned — do not pollute either slot.
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
const sets = await _primeSets(sandbox);
|
||||
sandbox.window._tunerAutoOpen.publishWorkingTuning(BASS_EADG);
|
||||
assert.equal(sets.length, 0);
|
||||
});
|
||||
|
||||
test('publish carries the player reference pitch so the slot is self-consistent', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 432 } });
|
||||
const sets = await _primeSets(sandbox);
|
||||
sandbox.window._tunerAutoOpen.publishWorkingTuning(DROP_D);
|
||||
assert.equal(sets.length, 1);
|
||||
assert.equal(sets[0].state.referencePitch, 432);
|
||||
});
|
||||
|
||||
test('publish skips when the instrument could not be confidently resolved (settings unreadable)', async () => {
|
||||
// No player settings → /api/settings reports not-ok → we never cached a confident
|
||||
// selection, so publish must NOT write to a guessed default slot.
|
||||
const sandbox = createTunerSandbox(); // no player
|
||||
const sets = await _primeSets(sandbox);
|
||||
sandbox.window._tunerAutoOpen.publishWorkingTuning(DROP_D);
|
||||
assert.equal(sets.length, 0);
|
||||
});
|
||||
|
||||
// ── #655 fix: transactional open — a dismiss during the audio-start await must not
|
||||
// re-enable the tuner afterward (no zombie enabled-but-hidden state). See issue #675.
|
||||
test('dismiss during the audio-start await does not leave a zombie enabled tuner', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
// Minimal UI so real enable() gets past the panel-show line to the audio-start await
|
||||
// (the default sandbox _tunerUI is a no-op that never creates uiContainer).
|
||||
const el = () => ({
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
||||
querySelector: () => null, appendChild() {}, remove() {}, style: {},
|
||||
});
|
||||
const origTunerUI = sandbox.window._tunerUI; // the sandbox's full no-op method set
|
||||
sandbox.window._tunerUI = (state, actions) => {
|
||||
const api = origTunerUI(state, actions);
|
||||
state.uiContainer = el();
|
||||
state.vizContainer = el();
|
||||
state.skipBtn = el();
|
||||
api.showMicError = api.showMicError || (() => {});
|
||||
return api;
|
||||
};
|
||||
// The OPEN's audio start resolves only AFTER a ×/Skip dismiss has landed — i.e. the
|
||||
// user dismissed while audio was starting. (disable()'s own background-audio resume
|
||||
// is a later call and resolves at once.)
|
||||
let firstStart = true;
|
||||
sandbox.window._tunerAudio.start = () => {
|
||||
if (!firstStart) return Promise.resolve();
|
||||
firstStart = false;
|
||||
return Promise.resolve().then(() => { sandbox.window.tuner.disable(); });
|
||||
};
|
||||
await sandbox.window.tuner.enable({ auto: true });
|
||||
assert.equal(sandbox.window._tunerAutoOpen.getState().enabled, false,
|
||||
'a mid-open dismiss must win — the tuner stays disabled, not enabled-but-hidden');
|
||||
});
|
||||
// ── #656 fix: coverage stays conservative when the instrument identity is unknown.
|
||||
// A fresh profile (/api/settings omits instrument/string_count/tuning) must NOT be
|
||||
// assumed to be standard guitar and silently suppress the prompt. See issue #677.
|
||||
test('coverage is conservative when settings carry no instrument identity', async () => {
|
||||
const sandbox = createTunerSandbox({ player: {} }); // settings object, but no instrument fields
|
||||
const covered = await sandbox.window._tunerAutoOpen.coveredByPlayerInstrument(E_STANDARD);
|
||||
assert.equal(covered, false, 'unknown instrument → not covered → still prompt');
|
||||
});
|
||||
|
||||
test('a configured standard-guitar player still covers a standard song', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
const covered = await sandbox.window._tunerAutoOpen.coveredByPlayerInstrument(E_STANDARD);
|
||||
assert.equal(covered, true, 'a known standard guitar covers a standard song (no regression)');
|
||||
});
|
||||
// ── #657 fix (#680) + #668 fix: coverage is deduped, and the player tuning is memoized
|
||||
// across songs (it depends on the selected instrument, not the song). Many coverage
|
||||
// calls — the auto-open gate, the badge cue, AND the library's per-song tuning-match
|
||||
// chips — share ONE /api/settings fetch until the instrument / working tuning changes.
|
||||
test('coverage reports share one /api/settings fetch across songs (player tuning memoized)', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
let settingsFetches = 0;
|
||||
const origFetch = sandbox.window.fetch;
|
||||
sandbox.window.fetch = (url) => {
|
||||
if (String(url).includes('/api/settings')) settingsFetches += 1;
|
||||
return origFetch(url);
|
||||
};
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
const [a, b] = await Promise.all([api.coverageReport(DROP_D), api.coverageReport(DROP_D)]);
|
||||
assert.equal(settingsFetches, 1, 'concurrent reports for the same song share one fetch');
|
||||
assert.deepEqual(a, b);
|
||||
// A different song re-evaluates coverage but reuses the memoized player tuning — the
|
||||
// player didn't retune or switch instruments, so no second /api/settings read.
|
||||
api.onSongLoading();
|
||||
await api.coverageReport(E_STANDARD);
|
||||
assert.equal(settingsFetches, 1, 'a different song reuses the memoized player tuning — no refetch');
|
||||
});
|
||||
|
||||
// ── #668 fix: a transient /api/settings failure must NOT be pinned by the player-tuning
|
||||
// memo — the next read retries (else one hiccup freezes coverage as "unknown" for good).
|
||||
test('a transient /api/settings failure is not cached — the next coverage read retries', async () => {
|
||||
const sandbox = createTunerSandbox({ player: { instrument: 'guitar', string_count: 6, tuning: 'Standard' } });
|
||||
let failNext = true;
|
||||
const origFetch = sandbox.window.fetch;
|
||||
sandbox.window.fetch = (url) => {
|
||||
if (String(url).includes('/api/settings') && failNext) { failNext = false; return Promise.reject(new Error('boom')); }
|
||||
return origFetch(url);
|
||||
};
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
const r1 = await api.coverageReport(DROP_D); // settings read failed → conservative "none" report
|
||||
assert.equal(r1.retune.length, 0, 'a fetch failure yields the empty/unknown report');
|
||||
api.onSongLoading(); // clear the coverage cache to force a recompute
|
||||
const r2 = await api.coverageReport(DROP_D); // retry: settings now readable → a real report
|
||||
assert.equal(r2.retune.length, 1, 'the retry actually computes coverage (Drop-D low string vs standard)');
|
||||
});
|
||||
// ── PR 9b: mic-verify (assumed -> verified via a per-string check) ──────────
|
||||
const VERIFY_TARGETS = [82.41, 110, 146.83, 196, 246.94, 329.63]; // guitar-6 standard freqs
|
||||
|
||||
test('mic-verify: all strings in-tune-and-stable promotes to verified (with the confirmed offsets)', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
const sets = [];
|
||||
sandbox.window.feedBack.workingTuning = { get: () => ({ offsets: [0, 0, 0, 0, 0, 0] }), set: (n, o) => sets.push({ n, o }) };
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
assert.ok(api.verifyStart(VERIFY_TARGETS, [-2, 0, 0, 0, 0, 0])); // verifying a Drop-D tuning
|
||||
for (let i = 0; i < 8; i++) api.verifyFeed(82.41, 2); // one string alone isn't enough
|
||||
assert.equal(api.verifyState().complete, false);
|
||||
for (const f of VERIFY_TARGETS) { for (let i = 0; i < 8; i++) api.verifyFeed(f, 2); }
|
||||
assert.equal(api.verifyState().complete, true);
|
||||
assert.equal(sets.length, 1);
|
||||
assert.equal(sets[0].o.provenance, 'verified');
|
||||
// The stamp carries the CONFIRMED offsets, not whatever stale offsets the slot held.
|
||||
assert.deepEqual(sets[0].n.offsets, [-2, 0, 0, 0, 0, 0]);
|
||||
assert.equal(sets[0].n.verifiedStrings.length, 6);
|
||||
});
|
||||
|
||||
test('mic-verify: derives the verified offsets from the tuning being checked (no song context)', () => {
|
||||
// A manually-selected tuning (no '_current' song) must still stamp the RIGHT offsets,
|
||||
// derived from the freqs being verified — not a stale currentSongOffsets.
|
||||
const sandbox = createTunerSandbox();
|
||||
const sets = [];
|
||||
sandbox.window.feedBack.workingTuning = { get: () => ({ offsets: [0, 0, 0, 0, 0, 0] }), set: (n, o) => sets.push({ n, o }) };
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
const DROP_D_FREQS = [73.42, 110, 146.83, 196, 246.94, 329.63]; // drop-D guitar
|
||||
assert.ok(api.verifyStart(DROP_D_FREQS)); // NO explicit offsets, no song context
|
||||
for (const f of DROP_D_FREQS) { for (let i = 0; i < 8; i++) api.verifyFeed(f, 2); }
|
||||
assert.equal(api.verifyState().complete, true);
|
||||
assert.equal(sets.length, 1);
|
||||
assert.deepEqual(Array.from(sets[0].n.offsets), [-2, 0, 0, 0, 0, 0]); // derived Drop D
|
||||
});
|
||||
|
||||
test('mic-verify: an out-of-tune string never completes; cancel clears', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window.feedBack.workingTuning = { get: () => ({}), set() {} };
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
api.verifyStart([82.41, 110]);
|
||||
for (let i = 0; i < 30; i++) api.verifyFeed(82.41, 25); // 25c off -> never passes
|
||||
assert.equal(api.verifyState().complete, false);
|
||||
assert.deepEqual(api.verifyState().done, [false, false]);
|
||||
api.verifyCancel();
|
||||
assert.equal(api.verifyState(), null);
|
||||
});
|
||||
|
||||
test('mic-verify: the in-tune streak resets if a frame drifts out', () => {
|
||||
const sandbox = createTunerSandbox();
|
||||
sandbox.window.feedBack.workingTuning = { get: () => ({}), set() {} };
|
||||
const api = sandbox.window._tunerAutoOpen;
|
||||
api.verifyStart([100]);
|
||||
for (let i = 0; i < 5; i++) api.verifyFeed(100, 2); // 5 in tune (need 8)
|
||||
api.verifyFeed(100, 30); // drift out -> streak resets
|
||||
for (let i = 0; i < 7; i++) api.verifyFeed(100, 2); // 7 more (not yet 8 in a row)
|
||||
assert.equal(api.verifyState().done[0], false);
|
||||
api.verifyFeed(100, 2); // 8th consecutive -> done
|
||||
assert.equal(api.verifyState().complete, true);
|
||||
});
|
||||
|
||||
test('the tuner exposes the mic-verify API and only it claims verified (screen.js)', () => {
|
||||
const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
|
||||
assert.match(src, /verifyStart:/);
|
||||
assert.match(src, /verifyFeed:/);
|
||||
assert.match(src, /provenance: 'verified'/); // the only place that stamps verified
|
||||
// A just-earned 'verified' is not clobbered by the assumed publish-on-clear.
|
||||
assert.match(src, /wasAutoOpened && !_verifiedPublished/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Pins the v3 Songs A–Z jump rail wiring in static/v3/songs.js.
|
||||
//
|
||||
// The rail lets a user jump the library grid to artists/titles starting with a
|
||||
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
|
||||
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
|
||||
// card's absolute index (cumulative of prior buckets), which converts to a
|
||||
// scrollTop — no page-through. The rail only offers letters the server reports
|
||||
// present for the active sort+filter (so a tap always lands on a real card). It
|
||||
// is shown only for the grid view + alphabetical (artist/title) sorts.
|
||||
//
|
||||
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.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('the rail is context-gated to grid view + alphabetical sorts', () => {
|
||||
// railSortColumn returns the active alpha column or null (recent/year/tuning).
|
||||
assert.match(src, /function\s+railSortColumn\s*\(\)/);
|
||||
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
|
||||
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
|
||||
'the rail must be visible only for the grid view + an alphabetical sort',
|
||||
);
|
||||
});
|
||||
|
||||
test('cards carry a data-letter bucket and non-A–Z buckets under #', () => {
|
||||
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
|
||||
'each card must tag its sort-letter bucket via songBucket(song)');
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
|
||||
'songBucket must bucket non-A–Z first chars under "#"',
|
||||
);
|
||||
});
|
||||
|
||||
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*\}\)/,
|
||||
'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)');
|
||||
// The legacy artist `letters` is only a valid fallback for an artist sort;
|
||||
// a title sort with no sort_letters hides the rail rather than mislabel it.
|
||||
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
|
||||
'refreshRail must only fall back to letters for an artist sort');
|
||||
// Absent letters are disabled (non-interactive), not just dimmed.
|
||||
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
|
||||
});
|
||||
|
||||
test('reload() refreshes the rail', () => {
|
||||
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
|
||||
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
|
||||
});
|
||||
|
||||
test('the rail + drag bubble are rendered in the Songs markup', () => {
|
||||
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
|
||||
assert.match(src, /id="v3-songs-azbubble"/);
|
||||
});
|
||||
|
||||
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
|
||||
// The cumulative-count seek: sum the song-counts of buckets ordered before
|
||||
// the target to get its first row's absolute index.
|
||||
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
|
||||
'jumpToLetter must derive the target index from sort_letters counts');
|
||||
assert.match(
|
||||
src,
|
||||
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
|
||||
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
|
||||
);
|
||||
// It pre-fetches the destination window so cards are ready when the scroll lands.
|
||||
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
|
||||
'jumpToLetter must pre-fetch the destination window before scrolling');
|
||||
// The old forward-paging helper is gone (the seek is O(1)).
|
||||
assert.doesNotMatch(src, /_loadNextAwait/,
|
||||
'the page-through helper must be removed under the windowed grid');
|
||||
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
|
||||
assert.match(src, /_jumpToken\s*!==\s*myToken/);
|
||||
});
|
||||
|
||||
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
|
||||
assert.match(src, /addEventListener\('pointerdown'/);
|
||||
assert.match(src, /addEventListener\('pointermove'/);
|
||||
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
|
||||
'arrow keys must move between present letters');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Pins the v3 "Save as collection" wiring in static/v3/songs.js (#636 item 2).
|
||||
// A smart collection is a saved live library filter, surfaced as a source in
|
||||
// the provider picker; the drawer can save the current filter set as one.
|
||||
// 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('currentFilterRules builds the raw query-param rule object', () => {
|
||||
assert.match(src, /function\s+currentFilterRules/);
|
||||
// Multi-value filters are CSV strings (what the backend stores / re-parses).
|
||||
assert.match(src, /r\.tunings\s*=\s*f\.tunings\.join\(','\)/);
|
||||
assert.match(src, /r\.arrangements_has\s*=\s*f\.arr_has\.join\(','\)/);
|
||||
});
|
||||
|
||||
test('saving POSTs to /api/collections with name + rules', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/fetch\('\/api\/collections',[\s\S]*?JSON\.stringify\(\{\s*name,\s*rules\s*\}\)/,
|
||||
'saveCurrentAsCollection must POST {name, rules} to /api/collections',
|
||||
);
|
||||
// After save, switch the source to the new collection and rebuild the UI.
|
||||
assert.match(src, /state\.provider\s*=\s*'collection:'\s*\+\s*col\.id/);
|
||||
});
|
||||
|
||||
test('the drawer shows a Save-as-collection action only when filters are set', () => {
|
||||
assert.match(src, /Object\.keys\(currentFilterRules\(\)\)\.length[\s\S]*?data-drawer-save/);
|
||||
assert.match(src, /data-drawer-save[\s\S]*?saveCurrentAsCollection/);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Pins the practice-aware library home in static/v3/songs.js:
|
||||
// - a "Repertoire" progress meter (mastered / total library songs), and
|
||||
// - a "Keep practicing" shelf (recently played, not yet mastered).
|
||||
// Both reuse existing data (/api/stats/best already in state.accuracy, and
|
||||
// /api/stats/recent) and are shown only on the unfiltered grid front door.
|
||||
//
|
||||
// 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('repertoire uses the same mastery threshold as the green accuracy badge', () => {
|
||||
assert.match(src, /const\s+MASTERY_ACCURACY\s*=\s*0\.9/);
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_repertoireCounts[\s\S]*?v\s*>=\s*MASTERY_ACCURACY\s*\)\s*mastered\+\+;\s*else\s+learning\+\+/,
|
||||
'repertoire counts must bucket scored songs into mastered/learning at MASTERY_ACCURACY',
|
||||
);
|
||||
});
|
||||
|
||||
test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+libHomeVisible[\s\S]*?state\.view === 'grid'[\s\S]*?state\.provider === 'local'[\s\S]*?!state\.selectMode[\s\S]*?!state\.q[\s\S]*?activeFilterCount\(\)\s*===\s*0/,
|
||||
'libHomeVisible must require grid view, the local provider, no select mode, no search, no active filters',
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
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',
|
||||
);
|
||||
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/,
|
||||
'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');
|
||||
});
|
||||
|
||||
test('the repertoire denominator is the unfiltered library total', () => {
|
||||
assert.match(src, /\/api\/library\/stats\?provider='/);
|
||||
assert.match(src, /total_songs\s*\?\?\s*stats\.total/);
|
||||
assert.match(src, /Math\.round\(\(mastered\s*\/\s*total\)\s*\*\s*100\)/);
|
||||
});
|
||||
|
||||
test('the home + #v3-lib-home host are wired into render and reload', () => {
|
||||
assert.match(src, /id="v3-lib-home"/, 'render() must include the #v3-lib-home host');
|
||||
assert.match(src, /function reload\s*\([\s\S]*?updateLibraryHome\(\)/,
|
||||
'reload() must refresh/toggle the home');
|
||||
assert.match(src, /function applyScoreRefresh[\s\S]*?renderLibraryHome\(\)/,
|
||||
'a new score must refresh the meter + shelf');
|
||||
});
|
||||
|
||||
test('shelf cards play the song on click', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/querySelectorAll\('\.v3-kp-card'\)[\s\S]*?window\.playSong\(enc\(fn\)/,
|
||||
'a shelf card click must call window.playSong with the recents filename',
|
||||
);
|
||||
});
|
||||
@@ -36,13 +36,15 @@ function makeStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function saveSnapshot(storage, state, scrollTop, page, loadedCount) {
|
||||
// Mirror of static/v3/songs.js _saveLibraryScrollSnapshot. Under the windowed
|
||||
// grid (#636 item 3 stage 2) geometry is stable, so the snapshot is just
|
||||
// {hash, scrollTop, view} — no page/loadedCount depth bookkeeping (restore sets
|
||||
// scrollTop and re-renders the window that maps to it).
|
||||
function saveSnapshot(storage, state, scrollTop) {
|
||||
const snap = {
|
||||
hash: buildLibraryStateHash(state),
|
||||
scrollTop,
|
||||
view: state.view,
|
||||
page,
|
||||
loadedCount,
|
||||
};
|
||||
storage.setItem(SCROLL_STATE_KEY, JSON.stringify(snap));
|
||||
}
|
||||
@@ -88,19 +90,21 @@ test('buildLibraryStateHash is stable for equivalent filter arrays', () => {
|
||||
assert.strictEqual(buildLibraryStateHash(s1), buildLibraryStateHash(s2));
|
||||
});
|
||||
|
||||
test('snapshot stores scrollTop and page', () => {
|
||||
test('snapshot stores scrollTop + view + hash (geometry-stable restore)', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 1840, 3, 96);
|
||||
saveSnapshot(storage, baseState, 1840);
|
||||
const snap = readSnapshot(storage);
|
||||
assert.strictEqual(snap.scrollTop, 1840);
|
||||
assert.strictEqual(snap.page, 3);
|
||||
assert.strictEqual(snap.loadedCount, 96);
|
||||
assert.strictEqual(snap.view, 'grid');
|
||||
assert.strictEqual(snap.hash, buildLibraryStateHash(baseState));
|
||||
// Page-depth bookkeeping is gone — the windowed grid restores from scrollTop.
|
||||
assert.strictEqual(snap.page, undefined);
|
||||
assert.strictEqual(snap.loadedCount, undefined);
|
||||
});
|
||||
|
||||
test('stale snapshot is detected when filters change', () => {
|
||||
const storage = makeStore();
|
||||
saveSnapshot(storage, baseState, 500, 1, 48);
|
||||
saveSnapshot(storage, baseState, 500);
|
||||
const snap = readSnapshot(storage);
|
||||
const changed = buildLibraryStateHash({ ...baseState, q: 'beatles' });
|
||||
assert.notStrictEqual(snap.hash, changed);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Guards the v3 text-selection policy (static/v3/v3.css + static/v3/index.html):
|
||||
// the UI defaults to non-selectable so accidental chrome selection can't look
|
||||
// broken, while form fields, plugin screens, and core content opt back in. A
|
||||
// future global reset clobbering the rule — or the content containers losing
|
||||
// their .fb-selectable opt-in — should fail here.
|
||||
//
|
||||
// Source-level only — same strategy as the other tests/js/ files.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
// Strip block comments so the policy's own explanatory prose (which quotes the
|
||||
// `* { user-select:none }` anti-pattern as a warning) can't trip the assertions.
|
||||
const css = fs.readFileSync(path.join(root, 'static', 'v3', 'v3.css'), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const html = fs.readFileSync(path.join(root, 'static', 'v3', 'index.html'), 'utf8');
|
||||
|
||||
test('v3 defaults to non-selectable on html (not a universal `*` rule)', () => {
|
||||
assert.match(css, /html\s*\{[^}]*user-select:\s*none/,
|
||||
'html must default user-select: none');
|
||||
// The `* { user-select: none }` anti-pattern breaks input carets / IME — must not exist.
|
||||
assert.doesNotMatch(css, /\*\s*\{[^}]*user-select:\s*none/,
|
||||
'must NOT use a universal `*` user-select:none rule');
|
||||
});
|
||||
|
||||
test('form fields are always re-enabled (caret / IME safe)', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/input,\s*textarea,\s*select[\s\S]*?contenteditable[\s\S]*?user-select:\s*text/,
|
||||
'input/textarea/select/[contenteditable] must be re-enabled to user-select: text',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin screen subtree stays selectable by inheritance (no `*`, respects plugin opt-outs)', () => {
|
||||
assert.match(
|
||||
css,
|
||||
/\.screen\[id\^="plugin-"\]\s*\{[^}]*user-select:\s*text/,
|
||||
'plugin screens must be re-enabled so plugin content is not silently un-copyable',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/\.screen\[id\^="plugin-"\]\s*\*/,
|
||||
'the plugin carve must NOT use `*` (would override a plugin\'s own non-select chrome)',
|
||||
);
|
||||
});
|
||||
|
||||
// The rule that re-enables selection on copyable content. Find the single
|
||||
// declaration block whose body sets `user-select: text`, then assert each
|
||||
// required selector is one of its selectors — order/format independent.
|
||||
const selectableRule = (css.match(/([^{}]*)\{[^}]*user-select:\s*text[^}]*\}/g) || [])
|
||||
.join('\n');
|
||||
|
||||
test('core content opts back in via .fb-selectable (element + descendants)', () => {
|
||||
assert.match(selectableRule, /\.fb-selectable\b/, '.fb-selectable must set user-select: text');
|
||||
assert.match(selectableRule, /\.fb-selectable\s*\*/, '...and its descendants (.fb-selectable *)');
|
||||
});
|
||||
|
||||
test('focused copyable surfaces (modals/toasts/scan banner) opt back in', () => {
|
||||
// The PR\'s a11y guardrail keeps copyable text selectable "incl. in
|
||||
// modals/toasts" — these carry errors / IDs / paths the user copies.
|
||||
assert.match(selectableRule, /\.feedBack-modal\b/, 'modals (.feedBack-modal) must be selectable');
|
||||
assert.match(selectableRule, /\[role="dialog"\]/, 'dialogs ([role="dialog"]) must be selectable');
|
||||
assert.match(selectableRule, /#fb-notify-stack\b/, 'toasts (#fb-notify-stack) must be selectable');
|
||||
assert.match(selectableRule, /#scan-banner\b/, 'the scan banner (#scan-banner) must be selectable');
|
||||
});
|
||||
|
||||
// Match a class="" attribute that contains ALL given tokens in any order.
|
||||
const hasClasses = (...tokens) => new RegExp(
|
||||
'class="' + tokens.map((t) => '(?=[^"]*\\b' + t + '\\b)').join('') + '[^"]*"');
|
||||
|
||||
test('the Settings panel and now-playing metadata carry .fb-selectable', () => {
|
||||
assert.match(html, hasClasses('fb-settings', 'fb-selectable'),
|
||||
'the Settings panel must opt back in (paths / version / diagnostics / About)');
|
||||
assert.match(html, hasClasses('fb-selectable', 'pointer-events-auto'),
|
||||
'the now-playing metadata must opt back in AND re-enable pointer-events '
|
||||
+ '(its #player-hud parent is pointer-events-none, which would block mouse selection)');
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// Behavioral harness for the host `window.feedBack.workingTuning` capability
|
||||
// (static/capabilities/working-tuning.js) — the per-instrument, in-memory current
|
||||
// tuning. Runs the real capability in a stubbed window (same strategy as
|
||||
// midi_input_domain.test.js) with a controllable fetch, and asserts the per-instrument
|
||||
// state machine: isolated guitar/bass slots, selector switch, defensive copies, the
|
||||
// provenance/verification invariant, unambiguous key routing, named-tuning seeding, and
|
||||
// the boot-race guard.
|
||||
|
||||
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 { 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 = {
|
||||
'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],
|
||||
},
|
||||
'bass-5': {
|
||||
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
},
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// `routes` maps a URL to: a plain JSON value (served as {ok:true}), a thenable that
|
||||
// resolves to a full response object (for deferral/races), or nothing (served {ok:false}).
|
||||
function loadWorkingTuning(routes = {}) {
|
||||
const window = createWindow();
|
||||
const changes = [];
|
||||
window.fetch = function (url) {
|
||||
const entry = routes[url];
|
||||
if (entry && typeof entry.then === 'function') return entry;
|
||||
if (entry !== undefined) return Promise.resolve({ ok: true, json: () => Promise.resolve(entry) });
|
||||
return Promise.resolve({ ok: false, json: () => Promise.resolve(null) });
|
||||
};
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(WORKING_TUNING_JS, 'utf8'), context, { filename: WORKING_TUNING_JS });
|
||||
// capabilities.js replaces window.feedBack with an EventTarget bus — subscribe on it,
|
||||
// not on window. Attaching after load still catches the async hydration event.
|
||||
window.feedBack.on('working-tuning-changed', (ev) => changes.push(ev.detail));
|
||||
return { window, wt: window.feedBack.workingTuning, changes };
|
||||
}
|
||||
|
||||
// Rebase a possibly-vm-realm array into this realm so deepStrictEqual compares by value,
|
||||
// not by (cross-realm) Array.prototype identity.
|
||||
const nums = (a) => (a == null ? a : Array.from(a));
|
||||
|
||||
// Drain the seed's fetch/promise chain (settings -> tunings -> hydrate).
|
||||
const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); };
|
||||
|
||||
test('registers a working-tuning exclusive-owner capability + versioned surface', () => {
|
||||
const { window, wt } = loadWorkingTuning();
|
||||
assert.equal(wt.version, 1);
|
||||
const pipeline = window.feedBack.capabilities.inspect('working-tuning');
|
||||
assert.ok(pipeline, 'working-tuning pipeline exists');
|
||||
const owner = (pipeline.participants || []).find((p) => p.pluginId === 'core.working-tuning');
|
||||
assert.ok(owner, 'core.working-tuning owner registered');
|
||||
for (const op of ['get-working-tuning', 'set-working-tuning']) {
|
||||
assert.ok(owner.operations.includes(op), `owner exposes ${op}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('get() defaults to a synchronous guitar-6 assumed seed before hydration', () => {
|
||||
const { wt } = loadWorkingTuning();
|
||||
const s = wt.get();
|
||||
assert.equal(s.instrument, 'guitar');
|
||||
assert.equal(s.stringCount, 6);
|
||||
assert.equal(s.provenance, 'assumed');
|
||||
assert.equal(s.offsets, null);
|
||||
});
|
||||
|
||||
test('per-instrument slots are isolated; the selector surfaces the right one', async () => {
|
||||
const { wt } = loadWorkingTuning();
|
||||
await flush();
|
||||
wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||
wt.set({ offsets: [0, 0, 0, 0] }, { instrument: 'bass-4' });
|
||||
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, 0, 0, 0, 0, 0]);
|
||||
assert.deepEqual(nums(wt.get('bass-4').offsets), [0, 0, 0, 0]);
|
||||
// Selecting an instrument makes get() (no arg) return that instrument's own state.
|
||||
wt.setCurrentInstrument('guitar', 6);
|
||||
assert.deepEqual(nums(wt.get().offsets), [-2, 0, 0, 0, 0, 0]);
|
||||
wt.setCurrentInstrument('bass', 4);
|
||||
assert.deepEqual(nums(wt.get().offsets), [0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('defensive copies: readers and post-set callers cannot mutate live state', async () => {
|
||||
const { wt } = loadWorkingTuning();
|
||||
await flush();
|
||||
const input = [-2, -2, -2, -2, -2, -2];
|
||||
wt.set({ offsets: input }, { instrument: 'guitar-6' });
|
||||
input[0] = 99; // mutate caller's array after set()
|
||||
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'set() stored a copy');
|
||||
const read = wt.get('guitar-6');
|
||||
read.offsets[0] = 99; // mutate a returned copy
|
||||
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-2, -2, -2, -2, -2, -2], 'get() returned a copy');
|
||||
});
|
||||
|
||||
test('verification invariant: verified <=> we hold verifiedStrings', async () => {
|
||||
const { wt } = loadWorkingTuning();
|
||||
await flush();
|
||||
// A complete verified bundle stamps verified + a timestamp.
|
||||
let s = wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
|
||||
{ instrument: 'guitar-6', provenance: 'verified' });
|
||||
assert.equal(s.provenance, 'verified');
|
||||
assert.deepEqual(nums(s.verifiedStrings), [1, 1, 1, 1, 1, 1]);
|
||||
assert.equal(typeof s.verifiedAt, 'number');
|
||||
|
||||
// Claiming verified on a tuning change WITHOUT fresh strings is impossible — it
|
||||
// fails toward assumed and drops the metadata (no "verified with null strings").
|
||||
s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6', provenance: 'verified' });
|
||||
assert.equal(s.provenance, 'assumed');
|
||||
assert.equal(s.verifiedStrings, null);
|
||||
assert.equal(s.verifiedAt, null);
|
||||
|
||||
// verified always carries a real timestamp — an explicit verifiedAt:null is stamped now.
|
||||
s = wt.set({ verifiedStrings: [1, 1, 1, 1, 1, 1], verifiedAt: null },
|
||||
{ instrument: 'guitar-6', provenance: 'verified' });
|
||||
assert.equal(s.provenance, 'verified');
|
||||
assert.equal(typeof s.verifiedAt, 'number');
|
||||
});
|
||||
|
||||
test('a tuning change invalidates a prior verification', async () => {
|
||||
const { wt } = loadWorkingTuning();
|
||||
await flush();
|
||||
wt.set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] },
|
||||
{ instrument: 'guitar-6', provenance: 'verified' });
|
||||
const s = wt.set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||
assert.equal(s.provenance, 'assumed');
|
||||
assert.equal(s.verifiedStrings, null);
|
||||
assert.equal(s.verifiedAt, null);
|
||||
});
|
||||
|
||||
test('bare-instrument writes target the current selection, not a hard-coded default', async () => {
|
||||
const { wt } = loadWorkingTuning();
|
||||
await flush();
|
||||
wt.setCurrentInstrument('bass', 5); // a 5-string bass is selected
|
||||
// A bare instrument string must write bass-5, not bass-4.
|
||||
wt.set({ offsets: [0, 0, 0, 0, 0] }, { instrument: 'bass' });
|
||||
assert.deepEqual(nums(wt.get('bass-5').offsets), [0, 0, 0, 0, 0]);
|
||||
assert.equal(wt.get('bass-4').offsets, null, 'bass-4 slot untouched');
|
||||
// A bare stringCount (no instrument) applies to the selected instrument.
|
||||
const s = wt.set({ stringCount: 5, offsets: [-1, -1, -1, -1, -1] });
|
||||
assert.equal(s.instrument, 'bass');
|
||||
assert.equal(s.stringCount, 5);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
await flush();
|
||||
const s = wt.get('guitar-6');
|
||||
assert.deepEqual(nums(s.offsets), [-2, 0, 0, 0, 0, 0], 'Drop D resolved to a -2 low string');
|
||||
assert.equal(s.source, 'settings');
|
||||
assert.equal(s.provenance, 'assumed');
|
||||
// Hydration emitted once, carrying the seeded instrument.
|
||||
const hydrations = changes.filter((c) => c.instrument === 'guitar');
|
||||
assert.ok(hydrations.length >= 1, 'a working-tuning-changed fired for the seeded instrument');
|
||||
});
|
||||
|
||||
test('seed accepts an offsets-list tuning directly', async () => {
|
||||
const { wt } = loadWorkingTuning({
|
||||
'/api/settings': { instrument: 'bass', string_count: 4, tuning: [-2, 0, 0, 0] },
|
||||
});
|
||||
await flush();
|
||||
assert.deepEqual(nums(wt.get('bass-4').offsets), [-2, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('boot race: an explicit set() before settings resolve is not clobbered by the seed', async () => {
|
||||
const settings = deferred();
|
||||
const { wt } = loadWorkingTuning({
|
||||
'/api/settings': settings.promise, // held open
|
||||
'/api/tunings': TUNINGS,
|
||||
});
|
||||
// A consumer writes before the seed lands.
|
||||
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
|
||||
// Now the seed resolves with a DIFFERENT tuning.
|
||||
settings.resolve({ ok: true, json: () => Promise.resolve({ instrument: 'guitar', string_count: 6, tuning: 'Drop D' }) });
|
||||
await flush();
|
||||
assert.deepEqual(nums(wt.get('guitar-6').offsets), [-5, -5, -5, -5, -5, -5], 'explicit write survived the seed');
|
||||
});
|
||||
|
||||
test('resetToDefault clears a slot back to its baseline and emits', async () => {
|
||||
const { wt, changes } = loadWorkingTuning();
|
||||
await flush();
|
||||
wt.set({ offsets: [-2, -2, -2, -2, -2, -2] }, { instrument: 'guitar-6', provenance: 'verified', verifiedStrings: [1, 1, 1, 1, 1, 1] });
|
||||
const before = changes.length;
|
||||
const s = wt.resetToDefault('guitar-6');
|
||||
assert.equal(s.offsets, null);
|
||||
assert.equal(s.provenance, 'assumed');
|
||||
assert.equal(s.verifiedStrings, null);
|
||||
assert.ok(changes.length > before, 'reset emitted working-tuning-changed');
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
// State-machine smoke tests for the host working-tuning capability
|
||||
// (static/capabilities/working-tuning.js). Runs the real module in a vm sandbox
|
||||
// with stubbed window.feedBack (emit/on + capabilities), localStorage and fetch.
|
||||
|
||||
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 SRC = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'static', 'capabilities', 'working-tuning.js'), 'utf8');
|
||||
|
||||
function makeSandbox(opts) {
|
||||
opts = opts || {};
|
||||
const listeners = {};
|
||||
const store = opts.localStorage || {};
|
||||
const settings = opts.settings || { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
const localStorage = {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
};
|
||||
const sandbox = {
|
||||
window: {
|
||||
feedBack: {
|
||||
capabilities: { version: 1, registerOwner() {}, registerParticipant() {} },
|
||||
emit(ev, detail) { (listeners[ev] || []).slice().forEach((fn) => fn(detail)); },
|
||||
on(ev, fn) { (listeners[ev] = listeners[ev] || []).push(fn); },
|
||||
},
|
||||
localStorage,
|
||||
},
|
||||
localStorage,
|
||||
fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve(settings) }),
|
||||
console, Promise, Date, Array, Object, JSON, Number, isFinite, setTimeout,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(SRC, sandbox);
|
||||
return {
|
||||
wt: () => sandbox.window.feedBack.workingTuning,
|
||||
emit: (ev, d) => sandbox.window.feedBack.emit(ev, d),
|
||||
on: (ev, fn) => sandbox.window.feedBack.on(ev, fn),
|
||||
reinject: () => vm.runInContext(SRC, sandbox),
|
||||
store,
|
||||
};
|
||||
}
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
test('defaults: an unset instrument is assumed with null offsets', () => {
|
||||
const d = makeSandbox().wt().get('guitar-6');
|
||||
assert.equal(d.offsets, null);
|
||||
assert.equal(d.provenance, 'assumed');
|
||||
});
|
||||
|
||||
test('set + get round-trips, per-instrument isolation (guitar vs bass)', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||
assert.deepEqual(s.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||
assert.equal(s.wt().get('bass-4').offsets, null);
|
||||
});
|
||||
|
||||
test('both directions: E → Drop D → back to E via set()', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||
assert.deepEqual(s.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||
s.wt().set({ offsets: [0, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||
assert.deepEqual(s.wt().get('guitar-6').offsets, [0, 0, 0, 0, 0, 0]); // came back
|
||||
});
|
||||
|
||||
test('changing the tuning invalidates a prior verification', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] }, { instrument: 'guitar-6', provenance: 'verified' });
|
||||
assert.equal(s.wt().get('guitar-6').provenance, 'verified');
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||
assert.equal(s.wt().get('guitar-6').provenance, 'assumed');
|
||||
});
|
||||
|
||||
test('verified decays to assumed on song:loading (offsets kept)', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [0, 0, 0, 0, 0, 0], verifiedStrings: [1, 1, 1, 1, 1, 1] }, { instrument: 'guitar-6', provenance: 'verified' });
|
||||
assert.equal(s.wt().get('guitar-6').provenance, 'verified');
|
||||
s.emit('song:loading', { filename: 'x' });
|
||||
assert.equal(s.wt().get('guitar-6').provenance, 'assumed');
|
||||
assert.deepEqual(s.wt().get('guitar-6').offsets, [0, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('resetToDefault clears back to defaults', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0] }, { instrument: 'guitar-6' });
|
||||
s.wt().resetToDefault('guitar-6');
|
||||
assert.equal(s.wt().get('guitar-6').offsets, null);
|
||||
});
|
||||
|
||||
test('launch default: setLaunchDefault persists + getLaunchDefault returns it', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||
s.wt().setLaunchDefault('guitar-6');
|
||||
assert.deepEqual(s.wt().getLaunchDefault('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||
assert.ok('v3-working-tuning-launch-default' in s.store);
|
||||
});
|
||||
|
||||
test('launch default is opt-in: none set → getLaunchDefault is null', () => {
|
||||
assert.equal(makeSandbox().wt().getLaunchDefault('guitar-6'), null);
|
||||
});
|
||||
|
||||
test('launch default seeds a fresh boot and wins over the raw profile', async () => {
|
||||
const store = {};
|
||||
const s1 = makeSandbox({ localStorage: store });
|
||||
s1.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||
s1.wt().setLaunchDefault('guitar-6');
|
||||
// Fresh boot with the SAME localStorage; settings say Standard → launch default wins.
|
||||
const s2 = makeSandbox({ localStorage: store, settings: { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 } });
|
||||
await tick();
|
||||
assert.deepEqual(s2.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]);
|
||||
assert.equal(s2.wt().get('guitar-6').source, 'launch-default');
|
||||
});
|
||||
|
||||
test('clearLaunchDefault removes it', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||
s.wt().setLaunchDefault('guitar-6');
|
||||
s.wt().clearLaunchDefault('guitar-6');
|
||||
assert.equal(s.wt().getLaunchDefault('guitar-6'), null);
|
||||
});
|
||||
|
||||
test('idempotent re-injection: a second load does NOT clobber live state', () => {
|
||||
const s = makeSandbox();
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||
s.reinject(); // run the module source again in the same context
|
||||
assert.deepEqual(s.wt().get('guitar-6').offsets, [-2, 0, 0, 0, 0, 0]); // preserved
|
||||
});
|
||||
|
||||
test('working-tuning-changed fires on set with the changed instrument', () => {
|
||||
const s = makeSandbox();
|
||||
const seen = [];
|
||||
s.on('working-tuning-changed', (e) => seen.push(e));
|
||||
s.wt().set({ offsets: [-2, 0, 0, 0, 0, 0], stringCount: 6 }, { instrument: 'guitar-6' });
|
||||
assert.ok(seen.length >= 1);
|
||||
const last = seen[seen.length - 1];
|
||||
assert.equal(last.instrument, 'guitar');
|
||||
assert.deepEqual(last.tuning.offsets, [-2, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
@@ -95,6 +95,20 @@ class TestConfigPersistence:
|
||||
client.post("/api/plugins/tuner/config", json={"audioInputMode": "browser"})
|
||||
assert client.get("/api/plugins/tuner/config").json()["audioInputMode"] == "browser"
|
||||
|
||||
def test_auto_open_defaults_false(self, client):
|
||||
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is False
|
||||
|
||||
def test_auto_open_true_accepted(self, client):
|
||||
client.post("/api/plugins/tuner/config", json={"autoOpenOnTuningChange": True})
|
||||
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is True
|
||||
|
||||
def test_auto_open_fail_closed_on_non_bool(self, client):
|
||||
# A hand-edited / bad-client non-boolean (e.g. the string "false") must NOT be
|
||||
# coerced to True by bool() — the opt-in stays off.
|
||||
for bad in ("false", "0", "1", "yes", 1, {}):
|
||||
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"]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Tests for smart/dynamic collections (got-feedback/feedBack#636 item 2).
|
||||
|
||||
A collection is a saved set of library filter rules, surfaced as a registered
|
||||
library provider so it inherits the v3 Songs UI. Storage reuses the playlists
|
||||
table (a `rules` JSON blob → smart collection); membership is the LIVE filter
|
||||
result, not stored songs.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _put(server_mod, *, filename, title, artist, tuning_name="E Standard", tuning_sort_key=0):
|
||||
server_mod.meta_db.put(filename, 1.0, 1, {
|
||||
"title": title, "artist": artist, "album": "LP", "year": "", "duration": 1.0,
|
||||
"tuning": tuning_name, "arrangements": [], "has_lyrics": False, "format": "archive",
|
||||
"stem_count": 0, "stem_ids": [], "tuning_name": tuning_name,
|
||||
"tuning_sort_key": tuning_sort_key, "tuning_offsets": "",
|
||||
})
|
||||
|
||||
|
||||
def _seed_mixed(server_mod):
|
||||
_put(server_mod, filename="d1.archive", title="Drop One", artist="Anna", tuning_name="Drop D", tuning_sort_key=-2)
|
||||
_put(server_mod, filename="d2.archive", title="Drop Two", artist="Bea", tuning_name="Drop D", tuning_sort_key=-2)
|
||||
_put(server_mod, filename="e1.archive", title="Std One", artist="Cy", tuning_name="E Standard")
|
||||
|
||||
|
||||
# ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_list_delete_collection(client):
|
||||
assert client.get("/api/collections").json() == {"collections": []}
|
||||
|
||||
r = client.post("/api/collections", json={"name": "Drop D stuff", "rules": {"tunings": ["Drop D"]}})
|
||||
assert r.status_code == 200
|
||||
col = r.json()["collection"]
|
||||
assert col["name"] == "Drop D stuff"
|
||||
assert col["rules"] == {"tunings": "Drop D"} # raw query-param format
|
||||
cid = col["id"]
|
||||
|
||||
listed = client.get("/api/collections").json()["collections"]
|
||||
assert [c["name"] for c in listed] == ["Drop D stuff"]
|
||||
|
||||
assert client.request("DELETE", f"/api/collections/{cid}").json() == {"ok": True}
|
||||
assert client.get("/api/collections").json() == {"collections": []}
|
||||
|
||||
|
||||
def test_create_requires_name_and_sanitizes_rules(client):
|
||||
assert client.post("/api/collections", json={"rules": {}}).status_code == 400
|
||||
# Unknown rule keys are dropped (never 500); known ones normalized to the
|
||||
# raw query-param format (list→CSV, favorites→1).
|
||||
col = client.post("/api/collections", json={
|
||||
"name": "Mix", "rules": {"tunings": ["Drop D", "Eb Standard"], "sort": "title", "bogus": "x", "favorites": True},
|
||||
}).json()["collection"]
|
||||
assert col["rules"] == {"tunings": "Drop D,Eb Standard", "sort": "title", "favorites": 1}
|
||||
|
||||
|
||||
def test_update_collection(client):
|
||||
cid = client.post("/api/collections", json={"name": "A", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
r = client.put(f"/api/collections/{cid}", json={"name": "B", "rules": {"format": "sloppak"}})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["collection"]["name"] == "B"
|
||||
assert r.json()["collection"]["rules"] == {"format": "sloppak"}
|
||||
assert client.put("/api/collections/99999", json={"name": "x"}).status_code == 404
|
||||
|
||||
|
||||
# ── Provider behaviour ──────────────────────────────────────────────────────
|
||||
|
||||
def test_collection_registers_as_a_provider(client, server_mod):
|
||||
_seed_mixed(server_mod)
|
||||
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
providers = client.get("/api/library/providers").json()["providers"]
|
||||
ids = [p["id"] for p in providers]
|
||||
assert f"collection:{cid}" in ids
|
||||
|
||||
|
||||
def test_collection_provider_returns_only_matching_songs(client, server_mod):
|
||||
_seed_mixed(server_mod)
|
||||
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
pid = f"collection:{cid}"
|
||||
|
||||
page = client.get("/api/library", params={"provider": pid}).json()
|
||||
titles = sorted(s["title"] for s in page["songs"])
|
||||
assert titles == ["Drop One", "Drop Two"] # E Standard song excluded
|
||||
|
||||
stats = client.get("/api/library/stats", params={"provider": pid}).json()
|
||||
assert stats["total_songs"] == 2
|
||||
|
||||
|
||||
def test_collection_provider_is_local_kind(client, server_mod):
|
||||
# kind="local" keeps the client's play/art paths on the local branch (a
|
||||
# collection's matched songs are local rows), not the remote-sync branch.
|
||||
cid = client.post("/api/collections", json={"name": "C", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
prov = next(p for p in client.get("/api/library/providers").json()["providers"]
|
||||
if p["id"] == f"collection:{cid}")
|
||||
assert prov["kind"] == "local"
|
||||
|
||||
|
||||
def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
|
||||
# A hand-edited / imported bad rules row (int where a string is expected, a
|
||||
# list for `sort`) must not crash the query — the provider re-sanitizes on
|
||||
# load. Write the bad JSON straight past the API sanitizer.
|
||||
_seed_mixed(server_mod)
|
||||
cid = client.post("/api/collections", json={"name": "Bad", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
# `artist: []` (list for a string field) and `sort: []` (unhashable) are
|
||||
# the values that would crash `.strip()` / `sort_map.get` if they reached a
|
||||
# query — they must be dropped, leaving the valid `tunings` rule intact.
|
||||
server_mod.meta_db.conn.execute(
|
||||
"UPDATE playlists SET rules = ? WHERE id = ?",
|
||||
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
|
||||
)
|
||||
server_mod.meta_db.conn.commit()
|
||||
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
|
||||
|
||||
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
|
||||
assert r.status_code == 200 # no 500/503 from bad rules
|
||||
assert sorted(s["title"] for s in r.json()["songs"]) == ["Drop One", "Drop Two"]
|
||||
|
||||
|
||||
def test_collection_provider_survives_restart(client, server_mod, tmp_path, monkeypatch):
|
||||
_seed_mixed(server_mod)
|
||||
cid = client.post("/api/collections", json={"name": "DropD", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
server_mod.meta_db.conn.close()
|
||||
# Re-import the server (same CONFIG_DIR) → boot scan must re-register it.
|
||||
sys.modules.pop("server", None)
|
||||
mod2 = importlib.import_module("server")
|
||||
try:
|
||||
ids = [p["id"] for p in mod2.library_providers.list()]
|
||||
assert f"collection:{cid}" in ids
|
||||
finally:
|
||||
mod2.meta_db.conn.close()
|
||||
|
||||
|
||||
# ── Isolation from manual playlists ─────────────────────────────────────────
|
||||
|
||||
def test_collections_excluded_from_playlists_and_are_read_only(client):
|
||||
cid = client.post("/api/collections", json={"name": "Coll", "rules": {"tunings": ["Drop D"]}}).json()["collection"]["id"]
|
||||
# Not listed among manual playlists...
|
||||
assert all(p["id"] != cid for p in client.get("/api/playlists").json())
|
||||
# ...and manual-playlist mutations 404 on a collection id (get_playlist gate).
|
||||
assert client.post(f"/api/playlists/{cid}/songs", json={"filename": "d1.archive"}).status_code == 404
|
||||
assert client.get(f"/api/playlists/{cid}").status_code == 404
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for feedpak contributor credits on the highway.
|
||||
|
||||
Covers the `_sanitize_authors` helper (unit) and the `song_info` WebSocket
|
||||
frame carrying the manifest `authors` list end-to-end (integration). The
|
||||
frontend uses a non-empty `authors` list to gate a credits overlay shown when
|
||||
a song loads, so loose/archive/synthetic plays must surface `[]`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ── _sanitize_authors unit tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
(tmp_path / "dlc").mkdir()
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sanitize_authors_valid(server_mod):
|
||||
out = server_mod._sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
|
||||
{"name": "Bob Lee", "role": "editor"},
|
||||
{"name": "Solo"},
|
||||
]
|
||||
}
|
||||
)
|
||||
# name + role only; email/url dropped; missing role → None.
|
||||
assert out == [
|
||||
{"name": "Azure", "role": "charter"},
|
||||
{"name": "Bob Lee", "role": "editor"},
|
||||
{"name": "Solo", "role": None},
|
||||
]
|
||||
|
||||
|
||||
def test_sanitize_authors_skips_malformed(server_mod):
|
||||
out = server_mod._sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": ""}, # blank name → skipped
|
||||
{"name": " "}, # whitespace name → skipped
|
||||
{"role": "mixer"}, # no name → skipped
|
||||
"not-a-dict", # non-dict → skipped
|
||||
{"name": " Kept ", "role": " arranger "}, # trimmed
|
||||
]
|
||||
}
|
||||
)
|
||||
assert out == [{"name": "Kept", "role": "arranger"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
|
||||
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
|
||||
assert server_mod._sanitize_authors(manifest) == []
|
||||
|
||||
|
||||
# ── song_info WS integration ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_sloppak(dlc_root, *, authors):
|
||||
pak = dlc_root / "authortest.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
(pak / "arrangements" / "lead.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"notes": [],
|
||||
"chords": [],
|
||||
"anchors": [],
|
||||
"handshapes": [],
|
||||
"templates": [],
|
||||
"beats": [{"time": 0.0, "measure": 1}],
|
||||
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
|
||||
}
|
||||
)
|
||||
)
|
||||
manifest = {
|
||||
"title": "Author Test",
|
||||
"artist": "Tester",
|
||||
"album": "",
|
||||
"year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [],
|
||||
}
|
||||
if authors is not None:
|
||||
manifest["authors"] = authors
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_client(tmp_path, monkeypatch):
|
||||
def _make():
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
yield _make
|
||||
server = sys.modules.get("server")
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _song_info(client, path):
|
||||
with client.websocket_connect(path) as ws:
|
||||
for _ in range(200):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("error"):
|
||||
raise AssertionError(f"WS error frame: {msg}")
|
||||
if msg.get("type") == "song_info":
|
||||
return msg
|
||||
if msg.get("type") == "ready":
|
||||
break
|
||||
raise AssertionError("no song_info frame received")
|
||||
|
||||
|
||||
def test_song_info_carries_authors(make_client):
|
||||
server = make_client()
|
||||
_write_sloppak(
|
||||
server._get_dlc_dir(),
|
||||
authors=[{"name": "Azure", "role": "charter", "email": "a@b.c"}],
|
||||
)
|
||||
with TestClient(server.app) as client:
|
||||
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
|
||||
assert info["authors"] == [{"name": "Azure", "role": "charter"}]
|
||||
|
||||
|
||||
def test_song_info_authors_empty_when_absent(make_client):
|
||||
server = make_client()
|
||||
_write_sloppak(server._get_dlc_dir(), authors=None)
|
||||
with TestClient(server.app) as client:
|
||||
info = _song_info(client, "/ws/highway/authortest.sloppak?arrangement=0")
|
||||
assert info["authors"] == []
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for instrument->chart arrangement routing in the highway WS.
|
||||
|
||||
When no explicit arrangement is requested, the WS picks the arrangement matching
|
||||
the player's selected instrument (config.json `instrument`) so a bass player gets
|
||||
the Bass part instead of the default Lead/guitar chart. An explicit arrangement
|
||||
request always wins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _arr(notes):
|
||||
return {
|
||||
"notes": notes,
|
||||
"chords": [],
|
||||
"anchors": [],
|
||||
"handshapes": [],
|
||||
"templates": [],
|
||||
"beats": [{"time": 0.0, "measure": 1}],
|
||||
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
|
||||
}
|
||||
|
||||
|
||||
def _write_multi_arr_sloppak(dlc_root):
|
||||
"""A song with a Lead (guitar) and a Bass arrangement, Lead first (index 0)."""
|
||||
pak = dlc_root / "multi.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
(pak / "arrangements" / "lead.json").write_text(json.dumps(_arr([])))
|
||||
(pak / "arrangements" / "bass.json").write_text(json.dumps(_arr([])))
|
||||
manifest = {
|
||||
"title": "Multi",
|
||||
"artist": "Tester",
|
||||
"album": "",
|
||||
"year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "bass", "name": "Bass", "file": "arrangements/bass.json"},
|
||||
],
|
||||
"stems": [],
|
||||
}
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
def _write_sloppak(dlc_root, name, arrangements):
|
||||
"""Write a .sloppak whose arrangements are (id, display-name) pairs, in order."""
|
||||
pak = dlc_root / f"{name}.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
manifest_arrs = []
|
||||
for arr_id, arr_name in arrangements:
|
||||
(pak / "arrangements" / f"{arr_id}.json").write_text(json.dumps(_arr([])))
|
||||
manifest_arrs.append(
|
||||
{"id": arr_id, "name": arr_name, "file": f"arrangements/{arr_id}.json"}
|
||||
)
|
||||
manifest = {
|
||||
"title": name,
|
||||
"artist": "Tester",
|
||||
"album": "",
|
||||
"year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": manifest_arrs,
|
||||
"stems": [],
|
||||
}
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_client(tmp_path, monkeypatch):
|
||||
def _make(instrument=None, default_arrangement=None):
|
||||
cfg = tmp_path / "config"
|
||||
cfg.mkdir(exist_ok=True)
|
||||
conf = {}
|
||||
if instrument is not None:
|
||||
conf["instrument"] = instrument
|
||||
if default_arrangement is not None:
|
||||
conf["default_arrangement"] = default_arrangement
|
||||
if conf:
|
||||
(cfg / "config.json").write_text(json.dumps(conf), encoding="utf-8")
|
||||
monkeypatch.setenv("CONFIG_DIR", str(cfg))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
yield _make
|
||||
server = sys.modules.get("server")
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _arr_index(client, path):
|
||||
with client.websocket_connect(path) as ws:
|
||||
for _ in range(200):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("error"):
|
||||
raise AssertionError(f"WS error frame: {msg}")
|
||||
if msg.get("type") == "song_info":
|
||||
return msg["arrangement_index"]
|
||||
if msg.get("type") == "ready":
|
||||
break
|
||||
raise AssertionError("no song_info frame received")
|
||||
|
||||
|
||||
def test_bass_instrument_routes_to_bass_arrangement(make_client):
|
||||
server = make_client(instrument="bass")
|
||||
_write_multi_arr_sloppak(server._get_dlc_dir())
|
||||
with TestClient(server.app) as client:
|
||||
# No explicit arrangement → route to Bass (index 1), not the default Lead.
|
||||
idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart")
|
||||
assert idx == 1
|
||||
|
||||
|
||||
def test_guitar_instrument_keeps_default(make_client):
|
||||
server = make_client(instrument="guitar")
|
||||
_write_multi_arr_sloppak(server._get_dlc_dir())
|
||||
with TestClient(server.app) as client:
|
||||
idx = _arr_index(client, "/ws/highway/multi.sloppak?naming_mode=smart")
|
||||
assert idx == 0 # guitar falls through to the default → Lead
|
||||
|
||||
|
||||
def test_explicit_arrangement_overrides_instrument(make_client):
|
||||
server = make_client(instrument="bass")
|
||||
_write_multi_arr_sloppak(server._get_dlc_dir())
|
||||
with TestClient(server.app) as client:
|
||||
# An explicit arrangement request wins even for a bass player.
|
||||
idx = _arr_index(client, "/ws/highway/multi.sloppak?arrangement=0")
|
||||
assert idx == 0
|
||||
|
||||
|
||||
def test_bass_with_no_bass_part_falls_through_to_guitar(make_client):
|
||||
server = make_client(instrument="bass")
|
||||
# Lead + Rhythm, no bass part at all.
|
||||
_write_sloppak(server._get_dlc_dir(), "gtr", [("lead", "Lead"), ("rhythm", "Rhythm")])
|
||||
with TestClient(server.app) as client:
|
||||
idx = _arr_index(client, "/ws/highway/gtr.sloppak")
|
||||
assert idx == 0 # no bass candidate → existing default (a guitar part)
|
||||
|
||||
|
||||
def test_bass_no_pref_picks_the_primary_bass_not_an_alt(make_client):
|
||||
server = make_client(instrument="bass")
|
||||
# Lead + two bass parts; the canonical "Bass" should win over "Bass 2".
|
||||
_write_sloppak(
|
||||
server._get_dlc_dir(), "bb",
|
||||
[("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")],
|
||||
)
|
||||
with TestClient(server.app) as client:
|
||||
idx = _arr_index(client, "/ws/highway/bb.sloppak")
|
||||
assert idx == 1 # the primary Bass, not the first-in-order-if-it-were-an-alt
|
||||
|
||||
|
||||
def test_bass_honors_saved_pref_within_the_bass_parts(make_client):
|
||||
# A bass player who saved "Bass 2" keeps it — instrument routing must not clobber
|
||||
# the preference with the primary Bass.
|
||||
server = make_client(instrument="bass", default_arrangement="Bass 2")
|
||||
_write_sloppak(
|
||||
server._get_dlc_dir(), "bb",
|
||||
[("lead", "Lead"), ("bass", "Bass"), ("bass2", "Bass 2")],
|
||||
)
|
||||
with TestClient(server.app) as client:
|
||||
idx = _arr_index(client, "/ws/highway/bb.sloppak")
|
||||
assert idx == 2 # the preferred Bass 2, not the primary Bass (index 1)
|
||||
|
||||
|
||||
def test_guitar_still_honors_saved_pref(make_client):
|
||||
# Guitar routing unchanged: a saved default_arrangement still applies.
|
||||
server = make_client(instrument="guitar", default_arrangement="Rhythm")
|
||||
_write_sloppak(
|
||||
server._get_dlc_dir(), "gtr2",
|
||||
[("lead", "Lead"), ("rhythm", "Rhythm"), ("bass", "Bass")],
|
||||
)
|
||||
with TestClient(server.app) as client:
|
||||
idx = _arr_index(client, "/ws/highway/gtr2.sloppak")
|
||||
assert idx == 1 # Rhythm, per preference
|
||||
@@ -398,6 +398,39 @@ def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_m
|
||||
assert stats["letters"] == {"#": 1}
|
||||
|
||||
|
||||
def test_query_stats_sort_letters_artist_counts_songs(client, server_mod):
|
||||
"""The v3 jump rail's `sort_letters` counts SONGS per first-letter bucket
|
||||
of the active sort column (vs `letters`, which counts distinct artists).
|
||||
Two songs by the same A-artist → letters {A:1}, sort_letters {A:2}."""
|
||||
_put(server_mod, filename="a1.archive", title="Song One", artist="Abba")
|
||||
_put(server_mod, filename="a2.archive", title="Song Two", artist="Abba")
|
||||
_put(server_mod, filename="b1.archive", title="Another", artist="Beck")
|
||||
_put(server_mod, filename="num.archive", title="Track", artist="2Pac")
|
||||
|
||||
# sort_letters=1 opts into the active-sort breakdown (the jump rail path).
|
||||
stats = client.get("/api/library/stats", params={"sort": "artist", "sort_letters": 1}).json()
|
||||
assert stats["letters"] == {"A": 1, "B": 1, "#": 1} # distinct artists
|
||||
assert stats["sort_letters"] == {"A": 2, "B": 1, "#": 1} # songs
|
||||
|
||||
# Without the opt-in, the extra breakdown is not computed or returned.
|
||||
plain = client.get("/api/library/stats", params={"sort": "artist"}).json()
|
||||
assert "sort_letters" not in plain
|
||||
assert plain["letters"] == {"A": 1, "B": 1, "#": 1}
|
||||
|
||||
|
||||
def test_query_stats_sort_letters_follow_title_sort(client, server_mod):
|
||||
"""With a title sort, the rail buckets key on the TITLE's first letter,
|
||||
not the artist's, so a tap lands on a real card in the grid's order."""
|
||||
_put(server_mod, filename="z1.archive", title="Apple", artist="Zztop")
|
||||
_put(server_mod, filename="z2.archive", title="Banana", artist="Zztop")
|
||||
|
||||
stats = client.get("/api/library/stats", params={"sort": "title", "sort_letters": 1}).json()
|
||||
assert stats["sort_letters"] == {"A": 1, "B": 1}
|
||||
# The legacy artist breakdown is unchanged regardless of sort — both songs
|
||||
# share one artist, so it stays a single distinct-artist Z bucket.
|
||||
assert stats["letters"] == {"Z": 1}
|
||||
|
||||
|
||||
def test_query_stats_ignores_null_letter_counts(server_mod):
|
||||
"""Legacy/corrupt rows can surface as NULL-ish letter aggregate
|
||||
rows on some SQLite builds. The stats endpoint should ignore those
|
||||
@@ -429,9 +462,13 @@ def test_query_stats_ignores_null_letter_counts(server_mod):
|
||||
server_mod.meta_db.conn.close()
|
||||
server_mod.meta_db.conn = FakeConn()
|
||||
|
||||
stats = server_mod.meta_db.query_stats()
|
||||
stats = server_mod.meta_db.query_stats(want_sort_letters=True)
|
||||
|
||||
assert stats == {"total_songs": 1, "total_artists": 1, "letters": {"T": 1}}
|
||||
# `sort_letters` (the v3 jump-rail breakdown) shares the GROUP BY letter
|
||||
# path in this fake, so it surfaces the same single live bucket when the
|
||||
# caller opts in.
|
||||
assert stats == {"total_songs": 1, "total_artists": 1,
|
||||
"letters": {"T": 1}, "sort_letters": {"T": 1}}
|
||||
|
||||
|
||||
def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Keyset (cursor) pagination for the library grid (feedBack#636 item 3, stage 1).
|
||||
|
||||
Pins the data layer the virtualized grid builds on:
|
||||
- every sort gets a unique `filename` tiebreak → a TOTAL order (fixes the
|
||||
latent OFFSET skip/dupe across equal-key rows);
|
||||
- `/api/library?after=<cursor>` walks the SAME total order with a WHERE-seek,
|
||||
returning exactly the OFFSET page would, with no gaps or dupes;
|
||||
- bad cursors / non-keyset sorts fall back to OFFSET safely.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _seed(server_mod, n=25, *, shared_artist=False):
|
||||
for i in range(n):
|
||||
artist = "SameArtist" if shared_artist else f"Artist{i:02d}"
|
||||
server_mod.meta_db.put(f"song{i:02d}.archive", float(i), 1, {
|
||||
"title": f"Title{i:02d}", "artist": artist, "album": "LP", "year": "",
|
||||
"duration": 1.0, "tuning": "E Standard", "arrangements": [], "has_lyrics": False,
|
||||
"format": "archive", "stem_count": 0, "stem_ids": [], "tuning_name": "E Standard",
|
||||
"tuning_sort_key": 0, "tuning_offsets": "",
|
||||
})
|
||||
|
||||
|
||||
def _walk_keyset(client, sort, size, total):
|
||||
"""Page the whole library via the cursor and return the filename order."""
|
||||
seen, cursor, guard = [], "", 0
|
||||
while len(seen) < total and guard < total + 5:
|
||||
guard += 1
|
||||
params = {"sort": sort, "size": size}
|
||||
if cursor:
|
||||
params["after"] = cursor
|
||||
body = client.get("/api/library", params=params).json()
|
||||
seen.extend(s["filename"] for s in body["songs"])
|
||||
cursor = body.get("next_cursor")
|
||||
if not body["songs"] or not cursor:
|
||||
break
|
||||
return seen
|
||||
|
||||
|
||||
def _walk_offset(client, sort, size, total):
|
||||
seen, page = [], 0
|
||||
while len(seen) < total:
|
||||
body = client.get("/api/library", params={"sort": sort, "size": size, "page": page}).json()
|
||||
if not body["songs"]:
|
||||
break
|
||||
seen.extend(s["filename"] for s in body["songs"])
|
||||
page += 1
|
||||
return seen
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "title", "title-desc", "recent"])
|
||||
def test_keyset_matches_offset_exactly(client, server_mod, sort):
|
||||
_seed(server_mod, 25)
|
||||
offset_order = _walk_offset(client, sort, 7, 25)
|
||||
keyset_order = _walk_keyset(client, sort, 7, 25)
|
||||
assert keyset_order == offset_order # same order...
|
||||
assert len(keyset_order) == 25
|
||||
assert len(set(keyset_order)) == 25 # ...no gaps, no dupes
|
||||
|
||||
|
||||
def test_stable_tiebreak_on_equal_keys(client, server_mod):
|
||||
# 25 songs, all the SAME artist → the artist sort is decided entirely by the
|
||||
# filename tiebreak. Both pagers must still cover all 25 with no dupe.
|
||||
_seed(server_mod, 25, shared_artist=True)
|
||||
keyset_order = _walk_keyset(client, "artist", 6, 25)
|
||||
assert len(keyset_order) == 25 and len(set(keyset_order)) == 25
|
||||
assert keyset_order == sorted(keyset_order) # tiebreak is filename ASC
|
||||
|
||||
|
||||
def test_first_page_has_cursor_and_no_after_is_offset(client, server_mod):
|
||||
_seed(server_mod, 5)
|
||||
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
|
||||
assert body["next_cursor"] # cursor offered
|
||||
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive"]
|
||||
|
||||
|
||||
def test_bad_cursor_falls_back_to_first_page(client, server_mod):
|
||||
_seed(server_mod, 5)
|
||||
body = client.get("/api/library", params={"sort": "artist", "size": 3, "after": "not-a-cursor"}).json()
|
||||
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive", "song02.archive"]
|
||||
|
||||
|
||||
def test_legacy_dir_desc_keysets_correctly(client, server_mod):
|
||||
# The legacy `sort=artist&dir=desc` shape must keyset against a DESC order
|
||||
# (canonicalized to artist-desc), not seek `>` against it → no gaps/dupes.
|
||||
_seed(server_mod, 20)
|
||||
offset_order, page = [], 0
|
||||
while True:
|
||||
body = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 6, "page": page}).json()
|
||||
if not body["songs"]:
|
||||
break
|
||||
offset_order.extend(s["filename"] for s in body["songs"])
|
||||
page += 1
|
||||
keyset, cursor, guard = [], "", 0
|
||||
while len(keyset) < 20 and guard < 25:
|
||||
guard += 1
|
||||
params = {"sort": "artist", "dir": "desc", "size": 6}
|
||||
if cursor:
|
||||
params["after"] = cursor
|
||||
body = client.get("/api/library", params=params).json()
|
||||
keyset.extend(s["filename"] for s in body["songs"])
|
||||
cursor = body.get("next_cursor")
|
||||
if not body["songs"] or not cursor:
|
||||
break
|
||||
assert keyset == offset_order
|
||||
assert len(set(keyset)) == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "recent"])
|
||||
def test_keyset_handles_null_sort_keys(client, server_mod, sort):
|
||||
# NULL artist/mtime (corrupt/legacy rows past put()'s '' defaults) sort
|
||||
# first in ASC / last in DESC; keyset must cover them exactly like OFFSET.
|
||||
_seed(server_mod, 10)
|
||||
server_mod.meta_db.conn.executemany(
|
||||
"INSERT INTO songs (filename, mtime, size, title, artist) VALUES (?, NULL, 1, ?, NULL)",
|
||||
[("zznull1.archive", "ZZ1"), ("zznull2.archive", "ZZ2")],
|
||||
)
|
||||
server_mod.meta_db.conn.commit()
|
||||
offset_order = _walk_offset(client, sort, 4, 12)
|
||||
keyset_order = _walk_keyset(client, sort, 4, 12)
|
||||
assert keyset_order == offset_order
|
||||
assert len(keyset_order) == 12 and len(set(keyset_order)) == 12
|
||||
|
||||
|
||||
def test_non_keyset_sort_offers_no_cursor(client, server_mod):
|
||||
_seed(server_mod, 5)
|
||||
body = client.get("/api/library", params={"sort": "tuning", "size": 2}).json()
|
||||
assert body["next_cursor"] is None # compound sort → OFFSET only
|
||||
assert len(body["songs"]) == 2
|
||||
@@ -213,7 +213,9 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
|
||||
assert stats["letters"] == {"R": 1}
|
||||
assert "page" not in provider.stats_kwargs
|
||||
assert "size" not in provider.stats_kwargs
|
||||
assert "sort" not in provider.stats_kwargs
|
||||
# `sort` is forwarded to query_stats now (the v3 jump rail keys its
|
||||
# present-letter breakdown on the active sort column); defaults to "artist".
|
||||
assert provider.stats_kwargs.get("sort") == "artist"
|
||||
|
||||
tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json()
|
||||
assert tunings["tunings"][0]["name"] == "E Standard"
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Tests for the library-DB + custom-art half of the settings bundle
|
||||
(got-feedback/feedBack#636 item 1).
|
||||
|
||||
The base bundle (config + plugin files) is covered in test_settings_export.py;
|
||||
this file pins the additive `core_server_files` section:
|
||||
|
||||
- the live library DB is exported as a CONSISTENT single-file snapshot
|
||||
(SQLite online-backup), base64-encoded;
|
||||
- custom playlist covers / avatar are walked into the bundle;
|
||||
- on import the DB is STAGED to `web_library.db.restore` (never written
|
||||
over the live, open DB) and swapped in at next startup, clearing stale
|
||||
WAL sidecars; custom art is written immediately;
|
||||
- the whole thing round-trips: export → wipe → import → restart → data back.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _valid_db_bytes(tmp_path, name="mk.db", marker="x"):
|
||||
"""Bytes of a small, valid (quick_check-clean) SQLite database."""
|
||||
p = tmp_path / name
|
||||
c = sqlite3.connect(str(p))
|
||||
try:
|
||||
c.execute("CREATE TABLE t (x TEXT)")
|
||||
c.execute("INSERT INTO t VALUES (?)", (marker,))
|
||||
c.commit()
|
||||
finally:
|
||||
c.close()
|
||||
return p.read_bytes()
|
||||
|
||||
|
||||
def _seed_song(server_mod, filename="marker.archive", title="Marker", artist="Tester"):
|
||||
server_mod.meta_db.put(filename, 1.0, 1, {
|
||||
"title": title, "artist": artist, "album": "LP", "year": "",
|
||||
"duration": 200.0, "tuning": "E Standard", "arrangements": [],
|
||||
"has_lyrics": False, "format": "archive", "stem_count": 0,
|
||||
"stem_ids": [], "tuning_name": "E Standard", "tuning_sort_key": 0,
|
||||
"tuning_offsets": "",
|
||||
})
|
||||
|
||||
|
||||
# ── Export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_path):
|
||||
_seed_song(server_mod, filename="snap.archive", title="SnapSong")
|
||||
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
core = bundle["core_server_files"]
|
||||
assert "web_library.db" in core
|
||||
entry = core["web_library.db"]
|
||||
assert entry["encoding"] == "base64"
|
||||
|
||||
# The snapshot must be a complete, openable DB reflecting current data —
|
||||
# written to its own file (no WAL sidecar needed) and queryable.
|
||||
snap = tmp_path / "snapshot.db"
|
||||
snap.write_bytes(base64.b64decode(entry["data"]))
|
||||
conn = sqlite3.connect(str(snap))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
assert rows == [("SnapSong",)]
|
||||
|
||||
|
||||
def test_export_includes_custom_art_dirs(client, tmp_path):
|
||||
(tmp_path / "playlist_covers").mkdir()
|
||||
(tmp_path / "playlist_covers" / "3.png").write_bytes(b"\x89PNG-cover")
|
||||
(tmp_path / "avatars").mkdir()
|
||||
(tmp_path / "avatars" / "me.png").write_bytes(b"\x89PNG-avatar")
|
||||
|
||||
core = client.get("/api/settings/export").json()["core_server_files"]
|
||||
assert core["playlist_covers/3.png"]["encoding"] == "base64"
|
||||
assert base64.b64decode(core["playlist_covers/3.png"]["data"]) == b"\x89PNG-cover"
|
||||
assert base64.b64decode(core["avatars/me.png"]["data"]) == b"\x89PNG-avatar"
|
||||
|
||||
|
||||
# ── Import: DB is staged, never written over the live file ──────────────────
|
||||
|
||||
def test_import_stages_db_restore_without_touching_live_db(client, server_mod, tmp_path):
|
||||
live = tmp_path / "web_library.db"
|
||||
live_bytes_before = live.read_bytes()
|
||||
|
||||
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
"data": base64.b64encode(payload).decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["restart_required"] is True
|
||||
assert any("restart" in w.lower() for w in body["warnings"])
|
||||
assert "web_library.db" in body["applied"]["core_files"]
|
||||
|
||||
# Live DB untouched; the restore is staged beside it for next startup.
|
||||
assert live.read_bytes() == live_bytes_before
|
||||
assert (tmp_path / "web_library.db.restore").read_bytes() == payload
|
||||
|
||||
|
||||
def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, tmp_path):
|
||||
# The dangerous case: SQLite magic header but a corrupt body. It must be
|
||||
# refused at import — otherwise startup would delete the live DB and then
|
||||
# fail to open the bad restore.
|
||||
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
"data": base64.b64encode(corrupt).decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
|
||||
|
||||
def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
|
||||
# A truncated / wrong file staged as the restore would brick startup —
|
||||
# reject anything lacking the SQLite magic header, before touching disk.
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
"data": base64.b64encode(b"not a database").decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
|
||||
|
||||
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"playlist_covers/7.png": {"encoding": "base64",
|
||||
"data": base64.b64encode(b"cover7").decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["restart_required"] is False
|
||||
assert (tmp_path / "playlist_covers" / "7.png").read_bytes() == b"cover7"
|
||||
|
||||
|
||||
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
|
||||
secret = tmp_path.parent / "escape.txt"
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"../escape.txt": {"encoding": "base64",
|
||||
"data": base64.b64encode(b"pwned").decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
assert not secret.exists()
|
||||
|
||||
|
||||
def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_path):
|
||||
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
|
||||
# the rest of the bundle still applies.
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"audio_cache/x.ogg": {"encoding": "base64",
|
||||
"data": base64.b64encode(b"nope").decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert not (tmp_path / "audio_cache" / "x.ogg").exists()
|
||||
assert any("undeclared" in w.lower() for w in r.json()["warnings"])
|
||||
|
||||
|
||||
# ── Startup swap ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path):
|
||||
main = tmp_path / "web_library.db"
|
||||
new_db = _valid_db_bytes(tmp_path, name="new.db", marker="new")
|
||||
# Simulate a live DB with stale WAL sidecars + a (valid) staged restore.
|
||||
main.write_bytes(b"OLD-DB")
|
||||
(tmp_path / "web_library.db-wal").write_bytes(b"OLD-WAL")
|
||||
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
|
||||
(tmp_path / "web_library.db.restore").write_bytes(new_db)
|
||||
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
|
||||
assert main.read_bytes() == new_db # swapped in
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
assert not (tmp_path / "web_library.db-wal").exists() # stale sidecars gone
|
||||
assert not (tmp_path / "web_library.db-shm").exists()
|
||||
|
||||
|
||||
def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_path):
|
||||
# A corrupt staged restore must be thrown away WITHOUT destroying the
|
||||
# live DB — never brick startup or lose data for a bad bundle.
|
||||
main = tmp_path / "web_library.db"
|
||||
main.write_bytes(b"LIVE-GOOD-DB")
|
||||
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
|
||||
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
|
||||
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
|
||||
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
|
||||
|
||||
|
||||
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
|
||||
(tmp_path / "web_library.db").write_bytes(b"LIVE")
|
||||
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
|
||||
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
|
||||
|
||||
|
||||
# ── Full round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
||||
_seed_song(server_mod, filename="keepme.archive", title="KeepMe")
|
||||
bundle = client.get("/api/settings/export").json()
|
||||
|
||||
# Lose the data (a song removed from the live DB after the backup).
|
||||
server_mod.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", ("keepme.archive",))
|
||||
server_mod.meta_db.conn.commit()
|
||||
assert server_mod.meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM songs WHERE filename = ?", ("keepme.archive",)
|
||||
).fetchone()[0] == 0
|
||||
|
||||
# Re-import the bundle → DB staged, not yet live.
|
||||
r = client.post("/api/settings/import", json=bundle)
|
||||
assert r.status_code == 200 and r.json()["restart_required"] is True
|
||||
|
||||
# Simulate a restart: close the live conn, apply the staged restore,
|
||||
# reopen — the song is back.
|
||||
server_mod.meta_db.conn.close()
|
||||
server_mod._apply_pending_db_restore(tmp_path)
|
||||
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
assert rows == [("KeepMe",)]
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
|
||||
|
||||
# ── Failure modes ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
|
||||
# A backup that silently omits the library DB is a data-loss trap — the
|
||||
# export must error rather than hand back an incomplete-looking bundle.
|
||||
monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None)
|
||||
r = client.get("/api/settings/export")
|
||||
assert r.status_code == 500
|
||||
assert "library database" in r.json()["error"].lower()
|
||||
|
||||
|
||||
def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, monkeypatch):
|
||||
# If a later write in phase 2 fails, the request 500s — but a staged DB
|
||||
# restore must NOT survive to swap in on the next restart.
|
||||
payload = _valid_db_bytes(tmp_path, name="incoming.db")
|
||||
real_write = server_mod._atomic_write_file
|
||||
|
||||
def boom(target, data):
|
||||
if target.name == "config.json": # last write of the commit
|
||||
raise OSError("disk full")
|
||||
return real_write(target, data)
|
||||
|
||||
monkeypatch.setattr(server_mod, "_atomic_write_file", boom)
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
"data": base64.b64encode(payload).decode()},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 500
|
||||
assert not (tmp_path / "web_library.db.restore").exists()
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for .jsonc support in the sloppak/feedpak loaders (feedpak-spec §8).
|
||||
|
||||
When a manifest pointer resolves to a ``.jsonc`` file, the reader MUST strip
|
||||
C-style comments (``//`` line and ``/* */`` block) before parsing. These tests
|
||||
exercise every side-file reader in ``lib/sloppak.py`` (arrangement, notation,
|
||||
drum_tab, song_timeline, lyrics, keys) plus the arrangement/song_timeline
|
||||
reads in ``scripts/lift_keys_notation.py`` against ``.jsonc`` inputs, and pin
|
||||
the string-boundary rule (comment-like text inside a JSON string survives).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
from jsonc import parse_jsonc
|
||||
from scripts.lift_keys_notation import _load_song_beats
|
||||
|
||||
|
||||
# ── jsonc.parse_jsonc unit tests ─────────────────────────────────────────────
|
||||
|
||||
def test_parse_jsonc_strips_line_comment():
|
||||
assert parse_jsonc('{"a": 1 // c\n}') == {"a": 1}
|
||||
|
||||
|
||||
def test_parse_jsonc_strips_block_comment():
|
||||
assert parse_jsonc('{"a": /* x */ 2}') == {"a": 2}
|
||||
|
||||
|
||||
def test_parse_jsonc_strips_multiline_block_comment():
|
||||
text = '{\n /* multi\n line */\n "a": 1\n}'
|
||||
assert parse_jsonc(text) == {"a": 1}
|
||||
|
||||
|
||||
def test_parse_jsonc_preserves_comment_like_text_in_strings():
|
||||
# ``//`` and ``/*`` inside a JSON string literal must NOT be treated as
|
||||
# comments — the string-aware regex keeps them verbatim.
|
||||
text = '{"url": "https://x/y", "note": "// not a comment /* still not */"}'
|
||||
out = parse_jsonc(text)
|
||||
assert out["url"] == "https://x/y"
|
||||
assert out["note"] == "// not a comment /* still not */"
|
||||
|
||||
|
||||
def test_parse_jsonc_rejects_malformed():
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
parse_jsonc('{"a": // comment breaks value\n}')
|
||||
|
||||
|
||||
def test_parse_jsonc_plain_json_passes_through():
|
||||
assert parse_jsonc('{"a": 1}') == {"a": 1}
|
||||
|
||||
|
||||
# ── Fixture builder ──────────────────────────────────────────────────────────
|
||||
|
||||
def _base_arrangement(*, beats=None) -> dict:
|
||||
return {
|
||||
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": beats or [], "sections": [],
|
||||
}
|
||||
|
||||
|
||||
def _write_sloppak(root: Path, *, manifest_extras: dict, side_files: dict) -> Path:
|
||||
"""Build a minimal directory-form sloppak.
|
||||
|
||||
``side_files`` maps a pak-relative filename (e.g. ``"arrangements/lead.jsonc"``)
|
||||
to the exact text to write — so the caller controls comments / extensions.
|
||||
"""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
# A default lead arrangement file the manifest references; can be overridden
|
||||
# via side_files if the caller wants a .jsonc arrangement.
|
||||
if "arrangements/lead.json" not in side_files and "arrangements/lead.jsonc" not in side_files:
|
||||
(pak / "arrangements" / "lead.json").write_text(
|
||||
json.dumps(_base_arrangement())
|
||||
)
|
||||
for rel, text in side_files.items():
|
||||
target = pak / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
manifest = {
|
||||
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||
}
|
||||
manifest.update(manifest_extras)
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak_path: Path, tmp_path: Path):
|
||||
dlc_root = pak_path.parent
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
|
||||
|
||||
|
||||
# ── Arrangement .jsonc ───────────────────────────────────────────────────────
|
||||
|
||||
def test_load_arrangement_jsonc_with_comments(tmp_path: Path):
|
||||
arr_text = (
|
||||
'// lead chart\n'
|
||||
'{\n'
|
||||
' "name": "Lead",\n'
|
||||
' "tuning": [0, 0, 0, 0, 0, 0],\n'
|
||||
' "capo": 0,\n'
|
||||
' /* no notes yet */\n'
|
||||
' "notes": [{"t": 0.5, "s": 0, "f": 5, "sus": 0}],\n'
|
||||
' "chords": [], "anchors": [], "handshapes": [], "templates": [],\n'
|
||||
' "beats": [], "sections": []\n'
|
||||
'}'
|
||||
)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={
|
||||
"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.jsonc"}]
|
||||
},
|
||||
side_files={"arrangements/lead.jsonc": arr_text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert len(loaded.song.arrangements) == 1
|
||||
arr = loaded.song.arrangements[0]
|
||||
assert arr.name == "Lead"
|
||||
assert len(arr.notes) == 1
|
||||
assert arr.notes[0].fret == 5
|
||||
|
||||
|
||||
def test_load_arrangement_jsonc_malformed_is_skipped(tmp_path: Path):
|
||||
# A .jsonc file that is still invalid after stripping comments must be
|
||||
# skipped gracefully (the loader's existing permissive path), not crash.
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={
|
||||
"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.jsonc"}]
|
||||
},
|
||||
side_files={"arrangements/lead.jsonc": '{"notes": // broken\n}'},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
# The malformed arrangement is skipped; load still returns a song shell.
|
||||
assert len(loaded.song.arrangements) == 0
|
||||
|
||||
|
||||
# ── Notation .jsonc ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_notation_jsonc_with_comments(tmp_path: Path):
|
||||
from tests.test_sloppak_notation_load import VALID_NOTATION
|
||||
payload = VALID_NOTATION
|
||||
text = (
|
||||
'/* notation for keys */\n'
|
||||
+ json.dumps(payload)
|
||||
)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={
|
||||
"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.json",
|
||||
"notation": "notation_lead.jsonc"}]
|
||||
},
|
||||
side_files={"notation_lead.jsonc": text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.notation_by_id is not None
|
||||
assert "lead" in loaded.notation_by_id
|
||||
|
||||
|
||||
# ── drum_tab .jsonc ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_drum_tab_jsonc_with_comments(tmp_path: Path):
|
||||
payload = {
|
||||
"version": 1, "name": "Drums",
|
||||
"kit": [{"id": "kick", "name": "Kick"}],
|
||||
"hits": [{"t": 0.5, "p": "kick", "v": 110}],
|
||||
}
|
||||
text = '// drum tab\n' + json.dumps(payload)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={"drum_tab": "drum_tab.jsonc"},
|
||||
side_files={"drum_tab.jsonc": text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.drum_tab is not None
|
||||
assert loaded.drum_tab["hits"][0]["p"] == "kick"
|
||||
|
||||
|
||||
# ── song_timeline .jsonc ─────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_timeline_jsonc_with_comments(tmp_path: Path):
|
||||
payload = {
|
||||
"beats": [{"time": 0.0, "measure": 0}, {"time": 0.5, "measure": 0}],
|
||||
"sections": [{"name": "intro", "number": 0, "time": 0.0}],
|
||||
}
|
||||
text = '/* timeline */\n' + json.dumps(payload)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={"song_timeline": "song_timeline.jsonc"},
|
||||
side_files={"song_timeline.jsonc": text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.song_timeline is not None
|
||||
assert len(loaded.song.beats) == 2
|
||||
assert loaded.song.sections[0].name == "intro"
|
||||
|
||||
|
||||
# ── lyrics .jsonc ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_lyrics_jsonc_with_comments(tmp_path: Path):
|
||||
payload = [
|
||||
{"w": "Hel", "t": 0.0, "d": 0.2},
|
||||
{"w": "lo", "t": 0.2, "d": 0.3},
|
||||
]
|
||||
text = '// syllable lyrics\n' + json.dumps(payload)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={"lyrics": "lyrics.jsonc"},
|
||||
side_files={"lyrics.jsonc": text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert len(loaded.song.lyrics) == 2
|
||||
assert loaded.song.lyrics[0]["w"] == "Hel"
|
||||
|
||||
|
||||
# ── keys .jsonc ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_keys_jsonc_with_comments(tmp_path: Path):
|
||||
payload = {
|
||||
"version": 1,
|
||||
"events": [{"t": 0.0, "key": "Em", "scale": "natural_minor"}],
|
||||
}
|
||||
text = '/* key/scale track */\n' + json.dumps(payload)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={"keys": "keys.jsonc"},
|
||||
side_files={"keys.jsonc": text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.keys is not None
|
||||
assert loaded.keys["events"][0]["key"] == "Em"
|
||||
|
||||
|
||||
# ── Comment-like text inside strings is preserved end-to-end ─────────────────
|
||||
|
||||
def test_load_arrangement_jsonc_preserves_comment_like_string_values(tmp_path: Path):
|
||||
# A note whose fret label (if it had one) or a string field contains ``//``
|
||||
# must survive the strip. We put a ``//`` inside the arrangement name and a
|
||||
# ``/* */`` inside a beat section name to exercise both comment shapes.
|
||||
arr_text = (
|
||||
'{\n'
|
||||
' "name": "Lead // solo",\n'
|
||||
' "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,\n'
|
||||
' "notes": [], "chords": [], "anchors": [], "handshapes": [], "templates": [],\n'
|
||||
' "beats": [],\n'
|
||||
' "sections": [{"name": "verse /* important */", "number": 0, "time": 0.0}]\n'
|
||||
'}'
|
||||
)
|
||||
pak = _write_sloppak(
|
||||
tmp_path,
|
||||
manifest_extras={
|
||||
"arrangements": [{"id": "lead", "name": "Lead",
|
||||
"file": "arrangements/lead.jsonc"}]
|
||||
},
|
||||
side_files={"arrangements/lead.jsonc": arr_text},
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
arr = loaded.song.arrangements[0]
|
||||
# The manifest's ``name: Lead`` overrides the arrangement JSON's name, so
|
||||
# assert on the section name instead — it flows straight from the parsed
|
||||
# .jsonc with no manifest override, proving the comment-like text inside
|
||||
# the string survived the strip.
|
||||
assert arr.name == "Lead"
|
||||
assert loaded.song.sections[0].name == "verse /* important */"
|
||||
|
||||
|
||||
# ── lift_keys_notation reads .jsonc arrangements / song_timeline ─────────────
|
||||
|
||||
def test_lift_keys_notation_loads_song_beats_from_jsonc_timeline(tmp_path: Path):
|
||||
"""``_load_song_beats`` (the helper the lifter uses to find downbeats) must
|
||||
read a ``.jsonc`` song_timeline when the manifest points at one."""
|
||||
timeline_text = (
|
||||
'// timeline\n'
|
||||
+ json.dumps({
|
||||
"beats": [{"time": 0.0, "measure": 0}, {"time": 0.5, "measure": 0},
|
||||
{"time": 1.0, "measure": 1}],
|
||||
"sections": [],
|
||||
})
|
||||
)
|
||||
pak = tmp_path / "song.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
(pak / "arrangements" / "keys.json").write_text(json.dumps(_base_arrangement()))
|
||||
(pak / "song_timeline.jsonc").write_text(timeline_text, encoding="utf-8")
|
||||
manifest = {
|
||||
"title": "T", "artist": "A", "duration": 4.0,
|
||||
"arrangements": [{"id": "keys", "name": "Keys",
|
||||
"file": "arrangements/keys.json"}],
|
||||
"song_timeline": "song_timeline.jsonc",
|
||||
"stems": [{"id": "full", "file": "stems/full.ogg"}],
|
||||
}
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
beats = _load_song_beats(pak, manifest)
|
||||
assert len(beats) == 3
|
||||
assert beats[2]["measure"] == 1
|
||||
|
||||
|
||||
def test_lift_keys_notation_falls_back_to_jsonc_arrangement_beats(tmp_path: Path):
|
||||
"""When no song_timeline is present, ``_load_song_beats`` falls back to the
|
||||
first arrangement JSON carrying beats — and that arrangement may be ``.jsonc``."""
|
||||
arr_text = (
|
||||
'/* keys */\n'
|
||||
+ json.dumps({
|
||||
**_base_arrangement(),
|
||||
"beats": [{"time": 0.0, "measure": 0}, {"time": 1.0, "measure": 1}],
|
||||
})
|
||||
)
|
||||
pak = tmp_path / "song.sloppak"
|
||||
pak.mkdir()
|
||||
(pak / "arrangements").mkdir()
|
||||
(pak / "arrangements" / "keys.jsonc").write_text(arr_text, encoding="utf-8")
|
||||
manifest = {
|
||||
"title": "T", "artist": "A", "duration": 4.0,
|
||||
"arrangements": [{"id": "keys", "name": "Keys",
|
||||
"file": "arrangements/keys.jsonc"}],
|
||||
"stems": [{"id": "full", "file": "stems/full.ogg"}],
|
||||
}
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
beats = _load_song_beats(pak, manifest)
|
||||
assert len(beats) == 2
|
||||
assert beats[1]["measure"] == 1
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the wishlist / "wanted" list (got-feedback/feedBack#636 item 4).
|
||||
|
||||
A wishlist entry is a song the user does NOT own yet (the *arr Wanted/Monitored
|
||||
analogue), so it lives in its own `wanted` table keyed by descriptive identity
|
||||
rather than a local filename. Producers (the find_more ownership-diff, or a
|
||||
manual add) POST entries; the API is idempotent on identity so a re-run of an
|
||||
ownership-diff can't duplicate.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def test_add_list_remove_round_trip(client):
|
||||
assert client.get("/api/wanted").json() == {"wanted": []}
|
||||
|
||||
r = client.post("/api/wanted", json={"artist": "Tool", "title": "Lateralus",
|
||||
"source": "find_more", "source_ref": "cf:123"})
|
||||
assert r.status_code == 200
|
||||
row = r.json()["wanted"]
|
||||
assert (row["artist"], row["title"], row["source"]) == ("Tool", "Lateralus", "find_more")
|
||||
wid = row["id"]
|
||||
|
||||
listed = client.get("/api/wanted").json()["wanted"]
|
||||
assert [w["title"] for w in listed] == ["Lateralus"]
|
||||
|
||||
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": True}
|
||||
assert client.get("/api/wanted").json() == {"wanted": []}
|
||||
# Deleting an already-gone id is a no-op, not an error.
|
||||
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": False}
|
||||
|
||||
|
||||
def test_add_is_idempotent_on_identity(client, server_mod):
|
||||
payload = {"artist": "Rush", "title": "YYZ", "source": "find_more", "source_ref": "x1"}
|
||||
first = client.post("/api/wanted", json=payload).json()["wanted"]
|
||||
# Same identity (case-insensitive on artist/title) → no duplicate, same row.
|
||||
again = client.post("/api/wanted", json={**payload, "artist": "rush", "title": "yyz"}).json()["wanted"]
|
||||
assert first["id"] == again["id"]
|
||||
assert server_mod.meta_db.count_wanted() == 1
|
||||
|
||||
# A different source_ref is a distinct entry.
|
||||
client.post("/api/wanted", json={**payload, "source_ref": "x2"})
|
||||
assert server_mod.meta_db.count_wanted() == 2
|
||||
|
||||
|
||||
def test_newest_first_ordering(client, server_mod):
|
||||
for t in ("First", "Second", "Third"):
|
||||
server_mod.meta_db.add_wanted(artist="A", title=t, source="manual")
|
||||
titles = [w["title"] for w in client.get("/api/wanted").json()["wanted"]]
|
||||
assert titles == ["Third", "Second", "First"]
|
||||
|
||||
|
||||
def test_add_requires_artist_or_title(client):
|
||||
r = client.post("/api/wanted", json={"source": "manual"})
|
||||
assert r.status_code == 400
|
||||
r2 = client.post("/api/wanted", json={"artist": "", "title": " "})
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_add_defaults_source_to_manual(client):
|
||||
row = client.post("/api/wanted", json={"title": "Untitled"}).json()["wanted"]
|
||||
assert row["source"] == "manual"
|
||||
assert row["artist"] == ""
|
||||
|
||||
|
||||
def test_non_dict_body_rejected(client):
|
||||
# FastAPI's `data: dict` validation rejects a JSON array (422) before the
|
||||
# handler's own defensive isinstance guard; either way it's not a 2xx.
|
||||
assert client.post("/api/wanted", json=[]).status_code in (400, 422)
|
||||
|
||||
|
||||
def test_table_creation_is_idempotent(server_mod):
|
||||
# Re-running the CREATE TABLE / CREATE INDEX must not error or wipe rows —
|
||||
# pin the additive + idempotent migration guarantee (constitution IV).
|
||||
server_mod.meta_db.add_wanted(artist="Keep", title="Me")
|
||||
server_mod.meta_db.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wanted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '', source_ref TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '', created_at TEXT
|
||||
)
|
||||
""")
|
||||
server_mod.meta_db.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
|
||||
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
|
||||
)
|
||||
assert server_mod.meta_db.count_wanted() == 1
|
||||
Reference in New Issue
Block a user