mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 19:04:29 +00:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb3e4eaae | ||
|
|
233f5280e8 | ||
|
|
950e348357 | ||
|
|
a18a818e8b | ||
|
|
5cb4ea0623 | ||
|
|
fadaa154e9 | ||
|
|
e446b05a99 | ||
|
|
115c3529e9 | ||
|
|
1bccb8a9e8 | ||
|
|
010edc239b | ||
|
|
9fb63fd3b5 | ||
|
|
cb72c5ab34 | ||
|
|
92e78be62d | ||
|
|
1840170e95 | ||
|
|
7cbf9824b1 | ||
|
|
33146cc7f6 | ||
|
|
69c8ad4e0c | ||
|
|
a20dca21bb | ||
|
|
021ee55f2a | ||
|
|
1f621e5fe5 | ||
|
|
612b1f2e0d | ||
|
|
4f6dc233f1 | ||
|
|
5be70939e4 | ||
|
|
6aaa2dcf47 | ||
|
|
1a8540935b | ||
|
|
d567fd5597 | ||
|
|
b914612f9d | ||
|
|
de002cdc24 | ||
|
|
3100d68a45 | ||
|
|
6397a959a4 | ||
|
|
5f499a8a3a | ||
|
|
af1170cec3 | ||
|
|
3e036e3db6 | ||
|
|
92dc321fdf | ||
|
|
74cff4e0d6 | ||
|
|
18c4e229e1 | ||
|
|
bde25c0bc8 | ||
|
|
c7aa5a10b0 | ||
|
|
fa2d12222a | ||
|
|
73c5ab149e | ||
|
|
a65d8cfa13 | ||
|
|
a86abadb14 | ||
|
|
41e907fa52 | ||
|
|
2c1c6f7eac | ||
|
|
b6169af6aa | ||
|
|
a3f1bceb15 | ||
|
|
c2153b277b | ||
|
|
2ffeeaca0b | ||
|
|
14eaad09e9 | ||
|
|
6ab1ed95c9 | ||
|
|
7c873f5cc2 | ||
|
|
68e29a8b6e | ||
|
|
d2b2a7e9f7 | ||
|
|
b6442dda75 | ||
|
|
336132e049 | ||
|
|
a2f43009f7 | ||
|
|
d27cbe78ba | ||
|
|
803bd0cdf3 | ||
|
|
9456790083 | ||
|
|
425f72b33f | ||
|
|
286c59707b | ||
|
|
97a941c45d | ||
|
|
9d6fdfe232 | ||
|
|
005270608b | ||
|
|
be9e965001 | ||
|
|
64a499975e | ||
|
|
8c7cde5d5c | ||
|
|
df2d660d1e | ||
|
|
f5c9c34291 | ||
|
|
7ca736d525 | ||
|
|
8e953e8bc4 | ||
|
|
7ef52cdd66 | ||
|
|
55060c4f67 | ||
|
|
7564934d06 | ||
|
|
28b0319e27 | ||
|
|
4b6cbe8b11 | ||
|
|
c7497c758d | ||
|
|
0a8c8945ea | ||
|
|
2e4383524f | ||
|
|
13db718bda | ||
|
|
727b8c8f24 | ||
|
|
15fabb62aa | ||
|
|
0d28886d46 | ||
|
|
fee85a14e7 | ||
|
|
80caf78306 | ||
|
|
d20b33348b | ||
|
|
7ff9261000 | ||
|
|
11c0f0483f | ||
|
|
74cd08f765 | ||
|
|
7c15cdda66 | ||
|
|
f6d8e241eb | ||
|
|
e9d95ad190 | ||
|
|
a47accd894 | ||
|
|
77e5a4982b | ||
|
|
feaaa5cd81 | ||
|
|
58e7407c38 | ||
|
|
22d299959f | ||
|
|
5e78f2f7f7 | ||
|
|
9b4bef3fd1 | ||
|
|
3e2703d8e1 | ||
|
|
d409d55615 | ||
|
|
4214ef365e | ||
|
|
80fc371f11 | ||
|
|
95b0786725 | ||
|
|
749af31cc3 | ||
|
|
991eadeff6 | ||
|
|
56419dc789 | ||
|
|
beefd7bc09 | ||
|
|
a7ea719652 | ||
|
|
95cb51b2ad | ||
|
|
59aa70ce5a | ||
|
|
1e9741043b | ||
|
|
1e2d5a6162 | ||
|
|
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 |
@@ -52,7 +52,7 @@ jobs:
|
||||
run: pytest
|
||||
|
||||
- name: Run JS plugin-API tests
|
||||
run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'
|
||||
run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js' 'plugins/*/tests/*.test.js'
|
||||
|
||||
tailwind-fresh:
|
||||
# Guard that the committed static/tailwind.min.css is in sync with source.
|
||||
@@ -123,3 +123,28 @@ jobs:
|
||||
sys.exit(1)
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
name: Nightly
|
||||
|
||||
# Trunk-based: nightly always builds main — the release-branch discovery
|
||||
# from the old release-centric flow is gone (it pinned nightlies to the
|
||||
# highest release/v* branch forever, even after it shipped). Stabilization
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
date: ${{ steps.date.outputs.date }}
|
||||
|
||||
steps:
|
||||
- name: Find active release branch
|
||||
id: branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
|
||||
--jq '[.[].ref | ltrimstr("refs/heads/")] | map(ltrimstr("refs/heads/")) | .[]' \
|
||||
| sort -V | tail -1 || true)
|
||||
if [[ -z "$branch" ]]; then
|
||||
branch="main"
|
||||
fi
|
||||
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
echo "Active branch: $branch"
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-docker:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -44,9 +22,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get date
|
||||
id: date
|
||||
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -65,6 +46,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/got-feedback/feedback:nightly
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
|
||||
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: rc
|
||||
|
||||
# Release-candidate images for stabilization: every push to a release/**
|
||||
# branch builds and pushes ghcr.io tags :rc (moving) and
|
||||
# :rc-<version>-<date> (pinned). Final versioned images still come from
|
||||
# release.yml on tag push.
|
||||
on:
|
||||
push:
|
||||
branches: ['release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One build per branch at a time; a newer push supersedes an in-flight one.
|
||||
concurrency:
|
||||
group: rc-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Derive RC tags
|
||||
id: meta
|
||||
run: |
|
||||
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
|
||||
version="${GITHUB_REF_NAME#release/}"
|
||||
version="${version#v}"
|
||||
date="$(date -u +%Y%m%d)"
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/got-feedback/feedback:rc"
|
||||
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
# stable releases (no pre-release suffix).
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
|
||||
fi
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -8,6 +8,11 @@ name: ship-ci
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, 'release/**']
|
||||
# Trunk-based: post-merge CI on main catches semantic conflicts between
|
||||
# independently-green PRs; push on release/** covers stabilization
|
||||
# cherry-picks that land without a PR.
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -44,6 +44,12 @@ plugins/minigames/__pycache__/
|
||||
plugins/tuner/__pycache__/
|
||||
!plugins/input_setup/
|
||||
!plugins/input_setup/**
|
||||
!plugins/drum_highway_3d/
|
||||
!plugins/drum_highway_3d/**
|
||||
plugins/drum_highway_3d/__pycache__/
|
||||
!plugins/keys_highway_3d/
|
||||
!plugins/keys_highway_3d/**
|
||||
plugins/keys_highway_3d/__pycache__/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
|
||||
features extend `app.js` and the existing globals (`window.playSong`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -256,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
|
||||
@@ -8,6 +8,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||
it away — and never read `time_signature` meta at all — so every MIDI import landed
|
||||
with no measures and an implied 4/4 regardless of what the file said. The new helper
|
||||
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
|
||||
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
|
||||
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
|
||||
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
|
||||
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
|
||||
timelines must never share a grid); mid-bar signature events apply at the next bar
|
||||
boundary; times are computed from absolute ticks through the cumulative tempo table
|
||||
and rounded once at emit, so rounding error never accumulates with song length.
|
||||
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
|
||||
`tests/test_midi_tempo_map.py`.
|
||||
|
||||
### Fixed
|
||||
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
|
||||
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1–127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
|
||||
|
||||
### Changed
|
||||
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||
|
||||
### Fixed
|
||||
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
|
||||
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
|
||||
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
|
||||
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
|
||||
|
||||
### Added
|
||||
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()` → `{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
|
||||
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
|
||||
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
|
||||
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
|
||||
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (0–1, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
|
||||
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 0–1 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
|
||||
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 0–1 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
|
||||
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
|
||||
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
|
||||
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
|
||||
- **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,6 +107,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||
|
||||
### Fixed
|
||||
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
|
||||
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
|
||||
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
|
||||
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport` → `{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
|
||||
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
|
||||
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two render with different idle colours — grid `text-white`, List View `text-fb-textDim` — and the handler only ever removed the grid's `text-white`. So in List View the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also writes the new state back onto the in-memory song model so a re-render / virtualized-grid recycle agrees instead of reverting. Tests: `tests/js/v3_favorites_toggle.test.js`.
|
||||
- **v3 Songs A–Z rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps** — `pointerdown` calls `setPointerCapture`, after which the browser **retargets the follow-up `click` to the rail container**, so the click handler's `closest('.v3-azrail-letter')` resolved `null` and a plain tap (no `pointermove`) had no other path → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); they now scale with the viewport (`clamp(.72rem, 1.4vh, 1.05rem)`), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
|
||||
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
|
||||
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
|
||||
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
|
||||
- **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).
|
||||
|
||||
@@ -117,6 +117,8 @@ Notes:
|
||||
|
||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
@@ -231,6 +233,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 +566,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
|
||||
|
||||
+8
-8
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
|
||||
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
||||
FROM alpine:3.20 AS ffmpeg-fetcher
|
||||
ARG TARGETARCH
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
|
||||
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
|
||||
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
|
||||
RUN apk add --no-cache curl xz \
|
||||
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
||||
&& case "$arch" in \
|
||||
@@ -94,9 +94,9 @@ FROM python:3.12-slim
|
||||
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
|
||||
# ARG values don't cross stage boundaries in multi-stage builds; defaults
|
||||
# must be repeated here to take effect when no --build-arg is supplied.
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
|
||||
|
||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||
# similar). Done first so any subsequent installs resolve against the
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# fee[dB]ack
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Description | Install |
|
||||
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
|
||||
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
|
||||
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
|
||||
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
|
||||
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
|
||||
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
|
||||
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
|
||||
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
|
||||
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
|
||||
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
|
||||
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
|
||||
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
||||
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
|
||||
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
||||
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
|
||||
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
|
||||
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
|
||||
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
||||
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
||||
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
|
||||
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
|
||||
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
||||
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
|
||||
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
|
||||
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
||||
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
||||
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
||||
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
||||
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
|
||||
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
||||
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||
|
||||
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
||||
|
||||
```bash
|
||||
cd plugins
|
||||
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
|
||||
docker compose restart
|
||||
```
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
+116
-56
@@ -55,59 +55,94 @@ The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not
|
||||
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
|
||||
resolves — themed or not. Roles:
|
||||
|
||||
`surface · card · border · text · text-dim · accent · accent-2 · good · warn · bad`
|
||||
plus two **new keystones**:
|
||||
**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.)
|
||||
|
||||
- **`on-accent`** — foreground legible *on* an accent fill. (Rule: any role used as a fill
|
||||
behind text gets a paired `on-*`.) Fixes white-on-amber.
|
||||
- **`focus-ring`** — focus indicator independent of accent, so focus stays visible when
|
||||
`accent ≈ surface`.
|
||||
**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.
|
||||
|
||||
`accent-2` is demoted to "just a second hue" — **never** an assumed gradient end.
|
||||
**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. A feature applies the
|
||||
slot bundle **unconditionally**; it never branches on "is this theme glowy?". Atomic slots
|
||||
(renames-by-intent of today's tokens): `corner-radius`, `corner-clip`, `panel-shadow`,
|
||||
`text-emph-shadow`, `panel-texture`, `motion-decorative` (reduced-motion-gated).
|
||||
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:
|
||||
`--emph-fill / --emph-border / --emph-halo / --emph-on`.
|
||||
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
|
||||
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
|
||||
None empty — each emphasises in its own language.
|
||||
- **ACCENT-TEXT** — how this theme fills a big accent number: `--acc-text-fill`
|
||||
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
|
||||
|
||||
On the existing `window.feedBack` bus:
|
||||
**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, tokens, isThemed }`
|
||||
- `feedBack.theme.capabilities()` → `{ glow, gradients, motion }` (the device-affordance signal)
|
||||
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once)
|
||||
- `theme:changed` event → `{ id, tokens, capabilities }` (emitted at theme-core's existing
|
||||
apply chokepoint; analogous to `note_detect`'s `notedetect:skin`)
|
||||
- `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 }`.
|
||||
|
||||
**Reconciliation rule (ends the two-disconnected-systems problem) — depends on whether the
|
||||
plugin has its own identity:**
|
||||
**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.
|
||||
|
||||
- **A plugin *without* its own skin** should **derive surface/text/border from host tokens**
|
||||
(`background: var(--fbv-card)`, etc.) and **own only its accent + its devices**, selecting the
|
||||
device via the recipe. A host theme then pulls its chrome along — one truth for surfaces — and
|
||||
it never imposes a device the active theme neutralizes. This is the common case.
|
||||
- **A plugin *with* deliberate skins** (e.g. `note_detect`'s neon / esports / metal, which are
|
||||
full design languages) **owns its surfaces** — deriving them from the host theme would *erase*
|
||||
the skin's identity (metal's brushed steel becomes a flat host colour). Such a plugin adopts the
|
||||
**role + recipe *pattern*** (per-skin device tokens, `on-accent`, `focus-ring`, "off" is legal)
|
||||
and verifies across **its own** skin matrix, but does not blindly inherit host surfaces.
|
||||
*(Decided 2026-06-29: keep note_detect's skins self-owned + their current button text — so the
|
||||
fix is the per-skin device pattern + the verification gate, not surface-derivation.)*
|
||||
**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)
|
||||
|
||||
@@ -117,28 +152,47 @@ plugin has its own identity:**
|
||||
|
||||
```css
|
||||
.hero-cta {
|
||||
background: var(--emph-fill);
|
||||
border: var(--emph-border);
|
||||
box-shadow: var(--emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
|
||||
color: var(--emph-on); /* never hardcoded #fff again */
|
||||
border-radius: var(--corner-radius);
|
||||
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 {
|
||||
color: var(--accent); /* legible solid fallback FIRST */
|
||||
background: var(--acc-text-fill);
|
||||
background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Accessibility (baked into the contract, not per-feature)
|
||||
**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:** `motion-decorative` is the *only* place decorative animation is named;
|
||||
one central rule sets it to `none` under `@media (prefers-reduced-motion: reduce)`, so no
|
||||
theme can forget the gate.
|
||||
- **Focus parity:** exactly one contract-level `:focus-visible { outline: 2px solid var(--focus-ring) }`;
|
||||
themes recolour `focus-ring` but may not author their own focus styling.
|
||||
- **On-accent contrast:** `on-accent` is required and **lintable** (`contrast(on-accent, accent) ≥ 4.5:1`,
|
||||
3:1 large). Contrast is the theme's job, computed once — not re-judged per feature.
|
||||
- **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)
|
||||
|
||||
@@ -157,13 +211,15 @@ stays legible when its slot resolves to `none`** · reduced-motion + focus parit
|
||||
|
||||
## 8. Back-compat & rollout
|
||||
|
||||
All additive: new `--fb-*` runtime vars + a new `feedBack.theme` namespace + a new event with no
|
||||
current listeners. Existing plugins (those reading `fb-*` utility classes, or shipping their own
|
||||
skins) are untouched unless they opt in; two-arg `var(--fb-x, fallback)` + `window.feedBack?.theme?.get`
|
||||
feature-detection means older hosts behave exactly as today.
|
||||
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** — always-present default `--fb-*` tokens + `feedBack.theme.{get,capabilities,prefersReducedMotion}` + `theme:changed`. *(the smallest thing that would have prevented the incident)*
|
||||
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.
|
||||
@@ -182,5 +238,9 @@ feature-detection means older hosts behave exactly as today.
|
||||
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.
|
||||
- Where the central reduced-motion + focus rules physically live (core base layer vs a shared
|
||||
plugin import) and how the host injects role tokens into plugin roots.
|
||||
|
||||
*(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,67 @@
|
||||
# Perf baseline — module-migration refactor
|
||||
|
||||
The refactor promises "measured runtime wins, no hand-waved perf claims" and
|
||||
"screen-entry and frame-time no worse." This is the baseline to hold it to.
|
||||
Rerun the harness after every phase (R0 → R3c) and compare.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
# 1. start core against a library with real charts (see caveat below)
|
||||
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
|
||||
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
|
||||
|
||||
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
|
||||
```
|
||||
|
||||
The script prints a markdown block; paste it under "Results" below with the date
|
||||
and the commit it was taken at.
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
|
||||
`/api/plugins`, `/api/library`, `/api/library/artists`.
|
||||
- **Cold boot → interactive** — full page load to `networkidle`.
|
||||
- **JS heap** — `performance.memory.usedJSHeapSize` after load and after an idle
|
||||
soak (a leak signal across a session).
|
||||
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
|
||||
"the app booted with its plugins" sanity signal).
|
||||
|
||||
**Not yet captured — needs a seeded library with charts** (fill in when run
|
||||
against a real environment): playback **frame-time p95** on the 2D and 3D
|
||||
highway, and **screen-entry** (plugin inject → interactive) for
|
||||
editor / notedetect / highway_3d with a chart loaded. These are the
|
||||
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
|
||||
the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
|
||||
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
|
||||
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
|
||||
> harness and lock the methodology.
|
||||
|
||||
Server latency (ms), n=50:
|
||||
|
||||
| Endpoint | status | p50 | p95 | p99 |
|
||||
|---|---|---|---|---|
|
||||
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
|
||||
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
|
||||
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
|
||||
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
|
||||
|
||||
Client:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Cold boot → networkidle | 1268 ms |
|
||||
| JS heap after load | 10.1 MB |
|
||||
| JS heap after idle soak | 10.1 MB (no idle growth) |
|
||||
| Plugin scripts injected | 12 |
|
||||
|
||||
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
|
||||
lands, cold-boot / heap should not regress.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plugin ES-module migration playbook
|
||||
|
||||
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
|
||||
ES-module graph — **no build step, no framework, no bundler**. This is the
|
||||
mechanism the monolith-killing refactor uses; the host rails for it shipped in
|
||||
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
|
||||
contract" in Operating Constraints).
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json + "scriptType": "module" ← opt in
|
||||
screen.js import './src/main.js'; ← the entire file
|
||||
src/
|
||||
state.js (0) module state + accessors
|
||||
util/… (1) pure helpers — real-import testable
|
||||
…/… (2..4) model → render/audio/io → input
|
||||
globals.js (5) THE ONLY file that writes window.*
|
||||
main.js (5) boot: wire modules, register screen:changed
|
||||
assets/… worklets / WASM / images (unchanged, served as today)
|
||||
```
|
||||
|
||||
`screen.js` becomes a one-line static `import`. The host injects it as
|
||||
`<script type="module">`, whose load event fires **only after the whole
|
||||
static-import graph fetches and evaluates** — so the loader's
|
||||
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
|
||||
order are all preserved. (A classic IIFE that fired a fire-and-forget
|
||||
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **Source-served, no build.** Modules are plain source files fetched from
|
||||
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
|
||||
2. **Layering points downward** — `state → util → commands/model →
|
||||
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
|
||||
enforces acyclicity; extract bottom-up so each move only imports
|
||||
already-extracted layers.
|
||||
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
|
||||
surface shrinks to one auditable file; everything else is module-scoped.
|
||||
4. **Import-time purity.** `node --test` runs a module's top-level code on
|
||||
import, so a module you want to unit-test must be side-effect-free at import:
|
||||
no `document` / `window` / `localStorage` at module top level — lift init
|
||||
into an exported `init()` called by `main.js`. (Constitution Principle V's
|
||||
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
|
||||
and use real `import`, retiring the regex/`extractFunction` harness.
|
||||
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
|
||||
inside a module. `assets/` lives at the plugin root, so a `src/` module must
|
||||
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
|
||||
import.meta.url)` (deeper modules need more `../`). Simpler and
|
||||
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
|
||||
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
|
||||
cannot share modules with `src/`.
|
||||
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
|
||||
`screen.js` once per version and `showScreen` re-injects nothing, so module
|
||||
top-level code does **not** re-run when the user re-enters the screen at the
|
||||
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
|
||||
'screen:changed', …)` handler — exactly as classic plugins (tuner,
|
||||
minigames) already do. Do not rely on the IIFE re-running.
|
||||
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
|
||||
keeps every referenced symbol on `window`); retire inline handlers to
|
||||
module-side `addEventListener` opportunistically, never as a blocking step.
|
||||
|
||||
## The live-edit loop
|
||||
|
||||
The host serves `screen.js`, `src/**`, and `assets/**` with
|
||||
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
|
||||
So: edit a `src/` file → **refresh the browser** → the edited module returns
|
||||
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
|
||||
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
|
||||
query on `screen.js` is the legacy version buster; it does **not** propagate
|
||||
into the `src/` graph and does not need to — ETag/mtime is the correctness
|
||||
authority for the whole graph.
|
||||
|
||||
## Host-version floor (`minHost`)
|
||||
|
||||
A migrated plugin *requires* a host new enough to serve `src/` and inject
|
||||
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
|
||||
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
|
||||
on an older host — is deferred, so bundled plugins are unaffected. Community
|
||||
plugins should state the floor and not migrate below it.)
|
||||
|
||||
## Migration mechanics
|
||||
|
||||
- **Move-only PRs.** One slice extracts one module: cut code, add
|
||||
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
|
||||
are separate PRs. (Init-lifts for import purity are the one non-pure move —
|
||||
budget them.)
|
||||
- **Bottom-up, layer by layer.** Within a layer, independent modules are
|
||||
independent PRs (a DAG, not a chain); use a git worktree per branch.
|
||||
- Tests move with their subject and convert to real `.mjs` imports in the same
|
||||
PR (assertions unchanged).
|
||||
- Size norm: no source file over **1,500 lines**; legitimate exceptions
|
||||
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
|
||||
|
||||
## Verifying a migration
|
||||
|
||||
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
|
||||
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
|
||||
Network); edit a `src/` file → refresh → change visible (`200` on the edited
|
||||
file, `304` on the rest); leave and re-enter the screen at the same version →
|
||||
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
|
||||
this end-to-end before the flagship repos migrate.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Theming a plugin so it works in every theme
|
||||
|
||||
This is the how-to for plugin authors. It exists because of one recurring bug:
|
||||
a plugin's UI is built and eyeballed against the **default** look, ships, and
|
||||
then a user switches themes and the feature's *colours adapt but its visual
|
||||
devices vanish* — a glow ring, a colour gradient — because the active theme
|
||||
doesn't speak that visual language.
|
||||
|
||||
**The golden rule:** themes are different **design languages**, not palettes.
|
||||
Reference **roles** and **recipe slots**; never hardcode a *device* (a literal
|
||||
`box-shadow` glow, a literal `linear-gradient`, a hex colour). Then your feature
|
||||
renders correctly in a theme nobody has invented yet — and **verify it across
|
||||
the whole theme set before you merge.**
|
||||
|
||||
(Design + rationale: [host-theme-contract.md](host-theme-contract.md), got-feedback/feedBack#644.)
|
||||
|
||||
## What the host gives you
|
||||
|
||||
Always-present CSS role tokens on `:root` (resolve themed *or* un-themed):
|
||||
`--fbv-bg / sidebar / card / cardMuted / primary / primaryHi / accent / text /
|
||||
textDim / border / good / mid / low / gold`, plus **`--fbv-on-accent`** (a
|
||||
foreground legible *on* an accent fill) and **`--fbv-focus-ring`**. Use as
|
||||
`color: rgb(var(--fbv-text))`, `background: rgb(var(--fbv-card))`, etc.
|
||||
|
||||
A JS read surface on the event bus:
|
||||
|
||||
```js
|
||||
const t = window.feedBack?.theme;
|
||||
t?.get(); // { id, isThemed, tokens }
|
||||
t?.capabilities(); // { glow, gradients, motion } — what devices this theme permits
|
||||
t?.prefersReducedMotion(); // boolean (one central matchMedia)
|
||||
window.feedBack.on('theme:changed', (e) => { /* e.detail = {id, tokens, capabilities} */ });
|
||||
```
|
||||
|
||||
Feature-detect everything (`window.feedBack?.theme?.get`) and pass a fallback to
|
||||
every `var(--fbv-x, <fallback>)` so you degrade cleanly on an older host.
|
||||
|
||||
## Pick your path
|
||||
|
||||
### Path A — you do NOT have your own skins (most plugins)
|
||||
|
||||
Look like the host. **Derive surfaces from host tokens** and **choose devices
|
||||
from capabilities** instead of hardcoding them:
|
||||
|
||||
```js
|
||||
const caps = window.feedBack?.theme?.capabilities?.() ?? { glow: true, motion: true };
|
||||
heroEl.classList.toggle('use-glow', caps.glow && !window.feedBack.theme.prefersReducedMotion());
|
||||
```
|
||||
```css
|
||||
.hero { background: rgb(var(--fbv-primary)); color: rgb(var(--fbv-on-accent, #fff)); }
|
||||
.hero:focus-visible { outline: 2px solid rgb(var(--fbv-focus-ring)); outline-offset: 2px; }
|
||||
.hero.use-glow { box-shadow: 0 0 16px -2px rgb(var(--fbv-primary)); } /* only where the theme allows it */
|
||||
```
|
||||
Re-read on `theme:changed` if you cache anything (e.g. a canvas palette).
|
||||
|
||||
### Path B — you HAVE your own deliberate skins (like the scoring UI)
|
||||
|
||||
Your skins are an identity (neon vs steel vs clean) — **own your surfaces**;
|
||||
don't inherit host surfaces or you'll erase that identity. Adopt only the
|
||||
**pattern**: make every visual *device* a **per-skin token whose "off" value is
|
||||
legal**, so each skin authors its own version and a glow-less skin simply
|
||||
doesn't glow. Example (the "make the hero special" device):
|
||||
|
||||
```css
|
||||
/* base / neon */ :root { --hero-ring: .5; --hero-border: transparent; --acc-fill: linear-gradient(135deg, var(--accent), var(--accent2)); }
|
||||
/* glow-less skin */ [data-skin="clean"] { --hero-ring: 0; --hero-border: var(--accent); --acc-fill: var(--accent); }
|
||||
.hero { background: var(--acc-fill); border-color: var(--hero-border); }
|
||||
.hero::after { opacity: var(--hero-ring); /* the glow ring; 0 = off */ }
|
||||
```
|
||||
Neon emphasises with the ring, the clean skin with a solid border — **neither is
|
||||
empty of emphasis; each speaks its own language.** Same idea for an accent-filled
|
||||
number (`--acc-fill` = a gradient in one skin, a solid in another so it doesn't
|
||||
wash out to white). Add `on-accent` + `focus-ring` per skin too.
|
||||
|
||||
## Accessibility (part of the contract, not an afterthought)
|
||||
|
||||
- **Reduced motion:** gate decorative animation on `prefers-reduced-motion`
|
||||
(CSS `@media`, or `theme.prefersReducedMotion()`); functional transitions are fine.
|
||||
- **Focus:** always a visible `:focus-visible` outline using `focus-ring` — don't
|
||||
bind focus only to `accent` (it can ≈ the surface in some themes).
|
||||
- **On-accent contrast:** text on an accent fill uses `on-accent`; watch light
|
||||
accents (a mid-amber needs dark text, not white).
|
||||
|
||||
## Before you merge — verify across the matrix
|
||||
|
||||
Render your UI in **every** theme/skin and look at them together. If you ship
|
||||
your own skins, do this with a render-matrix in CI/local (reference: the scoring
|
||||
UI's `npm run render-skins` + `theme-matrix-checklist.md`). The checklist that
|
||||
catches this bug class:
|
||||
|
||||
- [ ] colours via tokens, no hardcoded hexes
|
||||
- [ ] rendered in **all** themes/skins (not just the default)
|
||||
- [ ] every new *device* is per-skin / capability-gated and **legible when its token is `none`**
|
||||
- [ ] reduced-motion + visible focus in every theme
|
||||
- [ ] text on accent stays legible everywhere
|
||||
@@ -0,0 +1,62 @@
|
||||
# Size-exemption register
|
||||
|
||||
The working norm (constitution Principle II; enforced by the `max-lines` lint
|
||||
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
|
||||
it because splitting them would do more harm than good — hot per-frame
|
||||
renderers, C++, offline generators, cohesive registries. This register is the
|
||||
list of those exceptions: each row is a **deliberate, signed** decision with a
|
||||
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
|
||||
without a *signed* exemption" is unenforceable.
|
||||
|
||||
**Rules**
|
||||
- One row per file: a ceiling, a rationale, a signer, a review trigger.
|
||||
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
|
||||
keep them in sync (this register is canonical).
|
||||
- Files with a scheduled split **plan** are *not* exempt — they live in
|
||||
"Planned, not exempt" at the bottom so nothing falls between the two states.
|
||||
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
|
||||
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
|
||||
|
||||
## Permanent exemptions (structural rationale)
|
||||
|
||||
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,400–2,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
|
||||
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
|
||||
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
|
||||
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
|
||||
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
|
||||
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
|
||||
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
|
||||
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
|
||||
|
||||
## Split-when-touched (no scheduled train; row retires when split)
|
||||
|
||||
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
|
||||
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
|
||||
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
|
||||
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
|
||||
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
|
||||
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
|
||||
|
||||
## Temporary rows (cleared by a scheduled PR)
|
||||
|
||||
| Repo / file | Lines | Cleared by |
|
||||
|---|---|---|
|
||||
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
|
||||
|
||||
## Watch list (under the norm — no row needed, re-census each phase)
|
||||
|
||||
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
|
||||
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
|
||||
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
||||
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
by policy — the norm governs source files.
|
||||
@@ -0,0 +1,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,66 @@
|
||||
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
|
||||
// path (constitution Principle I: dev-only tooling is exempt, same category as
|
||||
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
|
||||
//
|
||||
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
|
||||
// monoliths warn (the "this is over the norm, split it" signal) and shrink
|
||||
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
|
||||
// exempted below, mirroring the signed register in docs/size-exemptions.md.
|
||||
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
|
||||
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
|
||||
// tests). no-unresolved (a HARD error) catches broken import paths;
|
||||
// no-cycle enforces the downward-only layering rule. Core's classic scripts
|
||||
// have no import graph, so both are dormant today and become live gates the
|
||||
// moment module code appears — validated against the first real module
|
||||
// plugin (R1 pilot).
|
||||
|
||||
const importX = require('eslint-plugin-import-x');
|
||||
|
||||
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
|
||||
// Keep in sync; each entry corresponds to a signed row in the register.
|
||||
const SIZE_EXEMPTIONS = [
|
||||
{ files: ['**/static/capabilities.js'], max: 1600 },
|
||||
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
|
||||
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
|
||||
];
|
||||
|
||||
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'static/vendor/**',
|
||||
'plugins/**/assets/vendor/**',
|
||||
'**/*.min.js',
|
||||
'static/tailwind.min.css',
|
||||
],
|
||||
},
|
||||
// Size norm across all first-party JS. Classic scripts are parsed as
|
||||
// scripts (no import/export); module files get their own block below.
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
// it the import rules silently skip imports they can't resolve.
|
||||
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
|
||||
rules: {
|
||||
'max-lines': sizeRule(1500),
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
|
||||
// registered files don't warn below it.
|
||||
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
|
||||
];
|
||||
@@ -0,0 +1,152 @@
|
||||
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
|
||||
|
||||
A flat MusicBrainz *text* search ties every take of a song at the same score —
|
||||
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
|
||||
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
|
||||
it). The definitive fix is content-based: fingerprint the actual audio with
|
||||
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
|
||||
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
|
||||
|
||||
This module is the PURE half (no network, no subprocess): response parsing +
|
||||
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
|
||||
subprocess and the throttled HTTP GET to api.acoustid.org.
|
||||
|
||||
Operational requirements (both optional — absent ⇒ this path is a graceful
|
||||
no-op and the text matcher still runs):
|
||||
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
|
||||
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
|
||||
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
|
||||
|
||||
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
|
||||
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
|
||||
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
|
||||
# AcoustID does NOT split into flags — it then attaches no recording metadata
|
||||
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
|
||||
# `releases` is what carries the per-release DATE (nested under each
|
||||
# releasegroup), which we need to pick the earliest original album + fill year.
|
||||
LOOKUP_META = "recordings releasegroups releases compress"
|
||||
|
||||
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
|
||||
# non-canonical (live/comp/remix) release, so we can flag the studio take.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
|
||||
def api_key(explicit: str | None = None) -> str:
|
||||
"""The AcoustID application API key: an explicit value (e.g. a host setting)
|
||||
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
|
||||
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
|
||||
|
||||
|
||||
def is_configured(explicit_key: str | None = None) -> bool:
|
||||
"""True when an API key is available. `fpcalc` presence is checked by
|
||||
server.py (it owns the binary lookup); both are required to actually run."""
|
||||
return bool(api_key(explicit_key))
|
||||
|
||||
|
||||
def _rg_is_studio(rg: dict) -> bool:
|
||||
if str(rg.get("type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _rg_earliest_year(rg: dict) -> "int | None":
|
||||
"""Earliest release YEAR in a release-group (min over its nested releases'
|
||||
dates). None when no release carries a date. This is what separates the
|
||||
original pressing from later reissues/comps sharing the same group."""
|
||||
years = []
|
||||
for rel in (rg.get("releases") or []):
|
||||
d = (rel or {}).get("date")
|
||||
if isinstance(d, dict) and d.get("year"):
|
||||
try:
|
||||
years.append(int(d["year"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(years) if years else None
|
||||
|
||||
|
||||
def _best_group(recording: dict) -> dict:
|
||||
"""Pick the display album: a clean studio Album first, and among those the
|
||||
EARLIEST-released one — the original, not a later reissue or a compilation
|
||||
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
|
||||
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
|
||||
the first group when nothing is a studio album or nothing carries a date."""
|
||||
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
|
||||
if not groups:
|
||||
return {}
|
||||
|
||||
def sort_key(g):
|
||||
yr = _rg_earliest_year(g)
|
||||
# studio (0) before non-studio (1); then earliest year (undated last).
|
||||
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
|
||||
|
||||
return sorted(groups, key=sort_key)[0]
|
||||
|
||||
|
||||
def _first_artist(recording: dict) -> str:
|
||||
for a in (recording.get("artists") or []):
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
return str(a["name"])
|
||||
return ""
|
||||
|
||||
|
||||
def parse_lookup_response(body: dict) -> list[dict]:
|
||||
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
|
||||
shape as mb_match (recording_id / title / artist / album / year / duration /
|
||||
studio / mb_score / score), so the review UI and the editor's Match popup
|
||||
render fingerprint hits and text hits identically. `mb_score` carries the
|
||||
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
|
||||
if not isinstance(body, dict) or body.get("status") != "ok":
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for result in (body.get("results") or []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
try:
|
||||
score = float(result.get("score") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
score = 0.0
|
||||
for rec in (result.get("recordings") or []):
|
||||
if not isinstance(rec, dict) or not rec.get("id"):
|
||||
continue
|
||||
rid = str(rec["id"])
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
rg = _best_group(rec)
|
||||
_yr = _rg_earliest_year(rg)
|
||||
year = str(_yr) if _yr else ""
|
||||
dur = rec.get("duration")
|
||||
try:
|
||||
duration = int(round(float(dur))) if dur else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
out.append({
|
||||
"recording_id": rid,
|
||||
"title": str(rec.get("title", "") or ""),
|
||||
"artist": _first_artist(rec),
|
||||
"album": str(rg.get("title", "") or ""),
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"isrc": "",
|
||||
"genres": [],
|
||||
"studio": _rg_is_studio(rg),
|
||||
"acoustid_score": round(score, 4),
|
||||
# Fingerprint hits are content-verified, not text-guessed — carry
|
||||
# the AcoustID confidence as the display score band.
|
||||
"mb_score": int(round(score * 100)),
|
||||
"score": round(score, 4),
|
||||
"source": "acoustid",
|
||||
})
|
||||
# Best AcoustID confidence first; studio take breaks ties.
|
||||
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
|
||||
return out
|
||||
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
|
||||
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
|
||||
)
|
||||
_SONG_FILENAME_RE = re.compile(
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
+43
-12
@@ -1114,7 +1114,7 @@ def _build_xml(
|
||||
ET.SubElement(root, "arrangement").text = arrangement
|
||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||
ET.SubElement(root, "artistName").text = artist
|
||||
ET.SubElement(root, "albumName").text = album
|
||||
@@ -1139,10 +1139,17 @@ def _build_xml(
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
|
||||
# Ebeats
|
||||
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
||||
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
|
||||
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
|
||||
# constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift
|
||||
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||
for b in beats:
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||
|
||||
# Sections
|
||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||
@@ -1836,9 +1843,10 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1894,18 +1902,29 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1946,9 +1965,21 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_rec["times"].sort()
|
||||
_vels = _rec.get("velocities")
|
||||
if _vels and len(_vels) == len(_rec["times"]):
|
||||
_pairs = sorted(zip(_rec["times"], _vels))
|
||||
_rec["times"] = [p[0] for p in _pairs]
|
||||
_rec["velocities"] = [p[1] for p in _pairs]
|
||||
else:
|
||||
# Belt-and-suspenders: times & velocities are always appended
|
||||
# together under the same `len(times) < 100` guard above, so
|
||||
# in practice the lengths can't diverge. Kept as a defensive
|
||||
# fallback, not a real divergence case.
|
||||
_rec["times"].sort()
|
||||
|
||||
return {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
+236
-143
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
|
||||
while sc <= max_sectors:
|
||||
s = _gi(po + 4 * sc); sc += 1
|
||||
if s == 0: break
|
||||
so = s * SECTOR
|
||||
if HDR + so + SECTOR > len(data):
|
||||
start = HDR + s * SECTOR
|
||||
# Real .gpx files' final sector is a few bytes short of a full
|
||||
# 0x1000 block: the BCFZ-declared decompressed size isn't
|
||||
# sector-aligned, so the last (small) container file lands in a
|
||||
# partial trailing sector. Clamp the read to the buffer end —
|
||||
# the per-file size field (`fs`, applied below) trims any
|
||||
# padding — matching canonical GPX readers (alphaTab /
|
||||
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
|
||||
# past the end is genuinely malformed.
|
||||
if start < 0 or start >= len(data):
|
||||
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
|
||||
fb.extend(data[HDR + so: HDR + so + SECTOR])
|
||||
fb.extend(data[start: min(start + SECTOR, len(data))])
|
||||
else:
|
||||
raise ValueError("GPX BCFS sector chain too long (malformed file)")
|
||||
files[fn] = bytes(fb[:fs])
|
||||
@@ -229,12 +237,29 @@ def _build_tempo_map(root: ET.Element) -> list[tuple[int, float]]:
|
||||
return events
|
||||
|
||||
|
||||
def _parse_tuning(el: ET.Element) -> list[int]:
|
||||
"""Return the string-tuning MIDI pitches from the first ``Tuning`` Property
|
||||
at or below ``el`` (a Track or a single Staff), high string first. ``[]`` if
|
||||
there is no Tuning property or its Pitches text is unparseable."""
|
||||
for prop in el.findall('.//Property'):
|
||||
if prop.get('name') == 'Tuning':
|
||||
pe = prop.find('Pitches')
|
||||
if pe is not None and pe.text:
|
||||
try:
|
||||
return [int(p) for p in pe.text.split()]
|
||||
except ValueError:
|
||||
return []
|
||||
break
|
||||
return []
|
||||
|
||||
|
||||
def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
"""Return a list of raw track dicts from the GPIF Tracks element."""
|
||||
# Lookups for per-track note counting. MasterBar/Bars lists one bar id per
|
||||
# track in raw Tracks order, so the enumerate index below (which counts
|
||||
# skipped pseudo-tracks) is the correct bar-lookup index — same mapping
|
||||
# convert_file uses via filtered_to_raw.
|
||||
# *stave* (not per Track element) in document order. A multi-stave track
|
||||
# (e.g. GP8 piano with treble + bass) occupies N consecutive columns; the
|
||||
# bar_column counter below advances by num_staves per track so every track
|
||||
# gets the correct column regardless of neighbour stave counts.
|
||||
_masterbars = list(root.find('MasterBars') or [])
|
||||
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
|
||||
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
|
||||
@@ -279,10 +304,17 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
return n
|
||||
|
||||
result = []
|
||||
for raw_idx, t in enumerate(root.find('Tracks') or []):
|
||||
bar_column = 0
|
||||
for t in (root.find('Tracks') or []):
|
||||
# Count staves: each Staff occupies one column in MasterBar/Bars.
|
||||
# Default to 1 for tracks with no explicit <Staves> (GP3/4/5, old GPX).
|
||||
num_staves = max(1, len(list(t.findall('Staves/Staff'))))
|
||||
stave_columns = list(range(bar_column, bar_column + num_staves))
|
||||
|
||||
name = (t.findtext('Name') or '').strip()
|
||||
if name.startswith('@$') and name.endswith('$@'):
|
||||
continue # GP internal pseudo-tracks (raw_idx still advances)
|
||||
bar_column += num_staves
|
||||
continue # GP internal pseudo-tracks (bar_column still advances)
|
||||
|
||||
gm = t.find('GeneralMidi')
|
||||
midi_program = 0
|
||||
@@ -319,27 +351,37 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# String tuning
|
||||
string_pitches: list[int] = []
|
||||
for prop in t.findall('.//Property'):
|
||||
if prop.get('name') == 'Tuning':
|
||||
pe = prop.find('Pitches')
|
||||
if pe is not None and pe.text:
|
||||
try:
|
||||
string_pitches = [int(p) for p in pe.text.split()]
|
||||
except ValueError:
|
||||
pass
|
||||
# String tuning — one list per stave, in stave order. Reading all
|
||||
# `.//Property` descendants across every stave meant the last stave's
|
||||
# tuning overwrote the first; for a GP8 piano (treble 6-string +
|
||||
# bass 5-string) that caused stave-0 notes with String=5 to be
|
||||
# out-of-range against the 5-entry bass tuning and silently dropped.
|
||||
# A staff with no Tuning of its own falls back to the track-level
|
||||
# property (never to []) — an empty list silently drops every fretted
|
||||
# note on that stave in `_note_midi`. The list stays parallel to
|
||||
# `stave_columns` so a per-stave column always has a matching tuning.
|
||||
_track_tuning = _parse_tuning(t)
|
||||
_staff_els = list(t.findall('Staves/Staff'))
|
||||
if _staff_els:
|
||||
stave_pitches = [(_parse_tuning(s) or _track_tuning) for s in _staff_els]
|
||||
else:
|
||||
# No <Staves> (GP3/4/5 or old GPX): single track-level tuning.
|
||||
stave_pitches = [_track_tuning]
|
||||
|
||||
result.append({
|
||||
'_el': t,
|
||||
'id': t.get('id', ''),
|
||||
'name': name,
|
||||
'string_pitches': string_pitches,
|
||||
'string_pitches': stave_pitches[0], # primary stave (existing key)
|
||||
'num_staves': num_staves,
|
||||
'stave_columns': stave_columns,
|
||||
'stave_pitches': stave_pitches,
|
||||
'is_drums': is_drums,
|
||||
'midi_program': midi_program,
|
||||
'midi_channel': midi_channel,
|
||||
'note_count': _note_count_for_raw(raw_idx),
|
||||
'note_count': sum(_note_count_for_raw(c) for c in stave_columns),
|
||||
})
|
||||
bar_column += num_staves
|
||||
return result
|
||||
|
||||
|
||||
@@ -367,6 +409,121 @@ def _beat_dur_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) ->
|
||||
return dur_qn * (60.0 / tempo_bpm)
|
||||
|
||||
|
||||
def _collect_column_notes(
|
||||
col: int,
|
||||
string_pitches: list[int],
|
||||
*,
|
||||
masterbars: list,
|
||||
bars_by_id: dict,
|
||||
voices_dict: dict,
|
||||
beats_dict: dict,
|
||||
notes_dict: dict,
|
||||
rhythms_dict: dict,
|
||||
tempo_map: list,
|
||||
tempo_bpm: float,
|
||||
audio_offset: float,
|
||||
) -> list['RsNote']:
|
||||
"""Walk one ``MasterBar/Bars`` column (a single stave / hand) and return its
|
||||
notes as keys-encoded ``RsNote`` (``string = midi // 24``, ``fret = midi %
|
||||
24``). Tie destinations extend the matching prior note's sustain (keyed by
|
||||
pitch, so polyphonic parts are handled) rather than emitting a new note —
|
||||
mirroring the main ``convert_file`` builder, including its full-precision
|
||||
timing and the 0.2s sustain threshold.
|
||||
|
||||
Shared by the GPX LH/RH pair merge and the GP8 multi-stave (grand-staff)
|
||||
fold so the two code paths can never drift in tie / timing / dedup handling.
|
||||
"""
|
||||
from gp2rs import RsNote # lazy: gp2rs<->gpx circular import (see convert_file)
|
||||
|
||||
notes: list[RsNote] = []
|
||||
last_per_key: dict[int, RsNote] = {}
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_bar, next_bpm = next(tempo_iter, (999999, tempo_bpm))
|
||||
cur_tempo = tempo_bpm
|
||||
t_cursor = 0.0
|
||||
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_bar:
|
||||
cur_tempo = next_bpm
|
||||
next_bar, next_bpm = next(tempo_iter, (999999, cur_tempo))
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
nb, db = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
nb, db = 4, 4
|
||||
bar_dur = nb * (4.0 / db) * (60.0 / cur_tempo)
|
||||
bar_ids = mb.findtext('Bars', '').split()
|
||||
bid = bar_ids[col] if col < len(bar_ids) else '-1'
|
||||
if bid != '-1' and bid:
|
||||
bar = bars_by_id.get(bid)
|
||||
if bar is not None:
|
||||
for vid in bar.findtext('Voices', '').split():
|
||||
if vid == '-1':
|
||||
continue
|
||||
voice = voices_dict.get(vid)
|
||||
if voice is None:
|
||||
continue
|
||||
vt = t_cursor
|
||||
for beat_id in voice.findtext('Beats', '').split():
|
||||
beat = beats_dict.get(beat_id)
|
||||
if beat is None:
|
||||
continue
|
||||
dur = _beat_dur_secs(beat, rhythms_dict, cur_tempo)
|
||||
for nid in beat.findtext('Notes', '').strip().split():
|
||||
note_el = notes_dict.get(nid)
|
||||
if note_el is None:
|
||||
continue
|
||||
if _note_is_tie(note_el):
|
||||
tie_midi = _note_midi(note_el, string_pitches)
|
||||
if tie_midi is not None:
|
||||
prev = last_per_key.get(tie_midi)
|
||||
tie_t = vt + audio_offset
|
||||
if prev is not None and prev.time < tie_t:
|
||||
prev.sustain = max(
|
||||
prev.sustain, (tie_t + dur) - prev.time)
|
||||
continue
|
||||
midi = _note_midi(note_el, string_pitches)
|
||||
if midi is None:
|
||||
continue
|
||||
rn = RsNote(
|
||||
time=vt + audio_offset,
|
||||
string=midi // 24,
|
||||
fret=midi % 24,
|
||||
sustain=dur if dur > 0.2 else 0.0,
|
||||
)
|
||||
notes.append(rn)
|
||||
last_per_key[midi] = rn
|
||||
vt += dur
|
||||
t_cursor += bar_dur
|
||||
return notes
|
||||
|
||||
|
||||
def _merge_lh_notes(rs_notes: list, rs_chords: list, lh_notes: list) -> None:
|
||||
"""Fold ``lh_notes`` (a second stave / left hand) into ``rs_notes`` in
|
||||
place, de-duplicating simultaneous same-pitch notes and keeping the LONGER
|
||||
sustain when both hands strike the same key at the same instant. Seeds the
|
||||
dedup set from chord notes too (polyphonic RH beats live in
|
||||
``rs_chords[*].notes``). No-op for an empty ``lh_notes``."""
|
||||
if not lh_notes:
|
||||
return
|
||||
seen: dict[tuple, RsNote] = {}
|
||||
for n in rs_notes:
|
||||
seen.setdefault((round(n.time, 3), n.string, n.fret), n)
|
||||
for c in rs_chords:
|
||||
for cn in c.notes:
|
||||
seen.setdefault((round(cn.time, 3), cn.string, cn.fret), cn)
|
||||
for rn in lh_notes:
|
||||
k = (round(rn.time, 3), rn.string, rn.fret)
|
||||
existing = seen.get(k)
|
||||
if existing is None:
|
||||
rs_notes.append(rn)
|
||||
seen[k] = rn
|
||||
elif rn.sustain > existing.sustain:
|
||||
# Mutating the RsNote also updates it in place inside any RH chord.
|
||||
existing.sustain = rn.sustain
|
||||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drum encoding tables — ported from alphaTab PercussionMapper (MIT licensed)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1349,6 +1506,10 @@ def convert_file(
|
||||
# Surface that to the caller rather than only the docstring: if the score
|
||||
# actually uses repeats, the produced bar count/timing will differ from the
|
||||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||||
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
|
||||
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
|
||||
# update that helper in the same change, or the editor's per-bar sync warp
|
||||
# would silently retime repeated sections onto the wrong bars.
|
||||
if expand_repeats and any(
|
||||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||||
for mb in masterbars
|
||||
@@ -1366,17 +1527,16 @@ def convert_file(
|
||||
rhythms_dict = {r.get('id'): r for r in (root.find('Rhythms') or [])}
|
||||
_bend_divisor = _gpx_bend_scale(root) # GPIF bend value -> semitones
|
||||
|
||||
# Map filtered track index -> raw track index (needed for bar lookup)
|
||||
raw_tracks = list(root.find('Tracks') or [])
|
||||
filtered_to_raw: dict[int, int] = {}
|
||||
filtered_pos = 0
|
||||
for raw_idx, t_el in enumerate(raw_tracks):
|
||||
name = (t_el.findtext('Name') or '').strip()
|
||||
if name.startswith('@$') and name.endswith('$@'):
|
||||
continue
|
||||
filtered_to_raw[filtered_pos] = raw_idx
|
||||
filtered_pos += 1
|
||||
|
||||
# Map filtered track index -> bar column (MasterBar/Bars position for
|
||||
# stave 0 of that track). `_gpif_tracks` already computed the per-stave
|
||||
# column layout (advancing by num_staves per track, pseudo-tracks skipped),
|
||||
# so reuse its `stave_columns[0]` rather than re-deriving the counting rule
|
||||
# here — divergence in stave counting *is* the bug class this fix closes.
|
||||
# NB: despite the historical name, the value is a bar *column*, not a raw
|
||||
# Track index — do not index `root.find('Tracks')` with it.
|
||||
filtered_to_raw: dict[int, int] = {
|
||||
i: t['stave_columns'][0] for i, t in enumerate(tracks)
|
||||
}
|
||||
|
||||
# Detect and merge Piano LH+RH pairs into single full-keyboard arrangements
|
||||
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
|
||||
@@ -1468,7 +1628,16 @@ def convert_file(
|
||||
is_keys = (
|
||||
not is_drum and not is_vocal
|
||||
and (
|
||||
any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||||
# A multi-stave track is a grand staff (treble + bass) — i.e. a
|
||||
# keyboard-family part. Treating it as keys end-to-end keeps the
|
||||
# stave-0 encoding and the folded stave-1+ encoding consistent
|
||||
# (both midi//24, midi%24) and makes the `note_count` preview
|
||||
# (which sums every stave column) match what actually imports,
|
||||
# even for instruments the name/program heuristics miss (harp,
|
||||
# celesta, marimba). GPIF writes guitars as a single Staff, so
|
||||
# this does not sweep in ordinary fretted tracks.
|
||||
track.get('num_staves', 1) > 1
|
||||
or any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
|
||||
or arr_name.lower().startswith('keys')
|
||||
or (
|
||||
not track['string_pitches']
|
||||
@@ -1534,7 +1703,6 @@ def convert_file(
|
||||
pending_slides: list = [] # (RsNote, rs_string, gp_slide_flags) — resolved post-loop
|
||||
|
||||
current_time = 0.0
|
||||
num_raw_tracks = len(raw_tracks)
|
||||
|
||||
# Resolve current tempo per bar from the tempo map
|
||||
_tempo_iter = iter(tempo_map)
|
||||
@@ -1866,112 +2034,20 @@ def convert_file(
|
||||
tuning = _gpx_tuning(track)
|
||||
|
||||
# Merge Piano LH notes into this (RH) arrangement if a pair was detected
|
||||
_walk_kwargs = dict(
|
||||
masterbars=masterbars, bars_by_id=bars_by_id,
|
||||
voices_dict=voices_dict, beats_dict=beats_dict,
|
||||
notes_dict=notes_dict, rhythms_dict=rhythms_dict,
|
||||
tempo_map=tempo_map, tempo_bpm=tempo_bpm, audio_offset=audio_offset,
|
||||
)
|
||||
if is_keys and track_idx in _piano_merge_map:
|
||||
# GPX LH/RH pair: the left hand is a *separate* Track element. Walk
|
||||
# its column and fold it into this (right-hand) arrangement.
|
||||
lh_idx = _piano_merge_map[track_idx]
|
||||
lh_track = tracks[lh_idx]
|
||||
lh_raw_idx = filtered_to_raw.get(lh_idx, lh_idx)
|
||||
|
||||
_lh_notes: list[RsNote] = []
|
||||
_lh_last_per_key: dict[int, RsNote] = {}
|
||||
_lh_tempo_iter = iter(tempo_map)
|
||||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, tempo_bpm))
|
||||
_lh_cur_tempo = tempo_bpm
|
||||
_lh_time = 0.0
|
||||
|
||||
for _lh_mb_idx, _lh_mb in enumerate(masterbars):
|
||||
while _lh_mb_idx >= _lh_next_bar:
|
||||
_lh_cur_tempo = _lh_next_bpm
|
||||
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, _lh_cur_tempo))
|
||||
_lh_ts = _lh_mb.findtext('Time', '4/4')
|
||||
try:
|
||||
_lh_nb, _lh_db = [int(x) for x in _lh_ts.split('/')]
|
||||
except ValueError:
|
||||
_lh_nb, _lh_db = 4, 4
|
||||
_lh_bar_dur = _lh_nb * (4.0 / _lh_db) * (60.0 / _lh_cur_tempo)
|
||||
_lh_bar_ids = _lh_mb.findtext('Bars', '').split()
|
||||
_lh_bid = _lh_bar_ids[lh_raw_idx] if lh_raw_idx < len(_lh_bar_ids) else '-1'
|
||||
if _lh_bid != '-1' and _lh_bid:
|
||||
_lh_bar = bars_by_id.get(_lh_bid)
|
||||
if _lh_bar is not None:
|
||||
for _lh_vid in _lh_bar.findtext('Voices', '').split():
|
||||
if _lh_vid == '-1':
|
||||
continue
|
||||
_lh_voice = voices_dict.get(_lh_vid)
|
||||
if _lh_voice is None:
|
||||
continue
|
||||
_lh_vt = _lh_time
|
||||
for _lh_beat_id in _lh_voice.findtext('Beats', '').split():
|
||||
_lh_beat = beats_dict.get(_lh_beat_id)
|
||||
if _lh_beat is None:
|
||||
continue
|
||||
_lh_dur = _beat_dur_secs(_lh_beat, rhythms_dict, _lh_cur_tempo)
|
||||
for _lh_nid in _lh_beat.findtext('Notes', '').strip().split():
|
||||
_lh_note_el = notes_dict.get(_lh_nid)
|
||||
if _lh_note_el is None:
|
||||
continue
|
||||
if _note_is_tie(_lh_note_el):
|
||||
# Extend the matching prior note (same
|
||||
# pitch), mirroring the main builder's
|
||||
# last_note_per_key handling — blindly
|
||||
# extending the last-emitted note
|
||||
# mishandles polyphonic (chord) LH parts.
|
||||
_tie_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||||
if _tie_midi is not None:
|
||||
_prev = _lh_last_per_key.get(_tie_midi)
|
||||
# Full-precision comparison (matching
|
||||
# the main builder); rounding only
|
||||
# happens at XML serialization. Rounding
|
||||
# here could make a short note appear to
|
||||
# start at the tie time and skip the
|
||||
# sustain extension.
|
||||
_tie_t = _lh_vt + audio_offset
|
||||
if _prev is not None and _prev.time < _tie_t:
|
||||
_prev.sustain = max(
|
||||
_prev.sustain,
|
||||
(_tie_t + _lh_dur) - _prev.time,
|
||||
)
|
||||
continue
|
||||
_lh_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
|
||||
if _lh_midi is None:
|
||||
continue
|
||||
# Keep full-precision time (like the main
|
||||
# convert_file() builder — rounding happens at
|
||||
# serialization); same 0.2s sustain threshold.
|
||||
_lh_rn = RsNote(
|
||||
time=_lh_vt + audio_offset,
|
||||
string=_lh_midi // 24,
|
||||
fret=_lh_midi % 24,
|
||||
sustain=_lh_dur if _lh_dur > 0.2 else 0.0,
|
||||
)
|
||||
_lh_notes.append(_lh_rn)
|
||||
_lh_last_per_key[_lh_midi] = _lh_rn
|
||||
_lh_vt += _lh_dur
|
||||
_lh_time += _lh_bar_dur
|
||||
|
||||
# Merge: combine and deduplicate simultaneous same-pitch notes, then
|
||||
# sort by time. Map each (time, string, fret) to its existing RsNote
|
||||
# so that when both hands hit the same key at the same instant we
|
||||
# keep the LONGER sustain instead of arbitrarily discarding the LH
|
||||
# one. Seed from both single notes and chord notes — polyphonic RH
|
||||
# beats live in rs_chords[*].notes, so seeding from rs_notes alone
|
||||
# would let an identical LH note slip in as a duplicate.
|
||||
_seen: dict[tuple, RsNote] = {}
|
||||
for _n in rs_notes:
|
||||
_seen.setdefault((round(_n.time, 3), _n.string, _n.fret), _n)
|
||||
for _c in rs_chords:
|
||||
for _cn in _c.notes:
|
||||
_seen.setdefault((round(_cn.time, 3), _cn.string, _cn.fret), _cn)
|
||||
for _lh_rn in _lh_notes:
|
||||
_k = (round(_lh_rn.time, 3), _lh_rn.string, _lh_rn.fret)
|
||||
_existing = _seen.get(_k)
|
||||
if _existing is None:
|
||||
rs_notes.append(_lh_rn)
|
||||
_seen[_k] = _lh_rn
|
||||
elif _lh_rn.sustain > _existing.sustain:
|
||||
# Same key both hands — preserve the longer sustain (mutating
|
||||
# the RsNote also updates it in place inside any RH chord).
|
||||
_existing.sustain = _lh_rn.sustain
|
||||
rs_notes.sort(key=lambda n: (n.time, n.string))
|
||||
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
|
||||
lh_raw_idx, lh_track['string_pitches'], **_walk_kwargs))
|
||||
|
||||
# Collapse "Keys 2" -> "Keys": the merged LH+RH is a single
|
||||
# keyboard arrangement. Keep the standard "Keys" name (not "Piano")
|
||||
@@ -1979,6 +2055,18 @@ def convert_file(
|
||||
# auto-select (which keys on arr_name.startswith("keys")) still work.
|
||||
arr_name = re.sub(r'\s*\d+$', '', arr_name).strip() or 'Keys'
|
||||
|
||||
elif track.get('num_staves', 1) > 1:
|
||||
# GP8 grand-staff keyboard: staves 1+ (bass clef, and any further
|
||||
# staves) are extra MasterBar/Bars columns for the SAME Track
|
||||
# element. Fold each one in, exactly like the GPX LH merge above.
|
||||
# (num_staves > 1 implies is_keys, set above.) Iterating every
|
||||
# extra column — not just stave_columns[1] — keeps the arrangement
|
||||
# consistent with note_count, which sums all columns.
|
||||
for _col, _sp in zip(track['stave_columns'][1:],
|
||||
track['stave_pitches'][1:]):
|
||||
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
|
||||
_col, _sp, **_walk_kwargs))
|
||||
|
||||
# Resolve pending slides now that every note on each string is known.
|
||||
# GPIF slide flags: 1=shift, 2=legato (both slide to the NEXT note on the
|
||||
# string); 4=slide out downwards, 8=slide out upwards (unpitched).
|
||||
@@ -2032,19 +2120,24 @@ def convert_file(
|
||||
try:
|
||||
import gp2notation as _gp2notation
|
||||
_lh_idx = _piano_merge_map.get(track_idx)
|
||||
if _lh_idx is not None:
|
||||
# GPX LH/RH pair (two separate Track elements)
|
||||
_nt_lh_raw = filtered_to_raw.get(_lh_idx, _lh_idx)
|
||||
_nt_lh_sp = tracks[_lh_idx]['string_pitches']
|
||||
elif track.get('num_staves', 1) > 1:
|
||||
# GP8 two-stave piano (one Track with multiple <Staves>)
|
||||
_nt_lh_raw = track['stave_columns'][1]
|
||||
_nt_lh_sp = (track['stave_pitches'][1]
|
||||
if len(track.get('stave_pitches', [])) > 1 else [])
|
||||
else:
|
||||
_nt_lh_raw, _nt_lh_sp = None, []
|
||||
_payload = _gp2notation.convert_track_to_notation(
|
||||
root, raw_idx, track['string_pitches'],
|
||||
instrument='piano',
|
||||
audio_offset=audio_offset,
|
||||
track_name=track['name'],
|
||||
lh_raw_idx=(
|
||||
filtered_to_raw.get(_lh_idx, _lh_idx)
|
||||
if _lh_idx is not None else None
|
||||
),
|
||||
lh_string_pitches=(
|
||||
tracks[_lh_idx]['string_pitches']
|
||||
if _lh_idx is not None else None
|
||||
),
|
||||
lh_raw_idx=_nt_lh_raw,
|
||||
lh_string_pitches=_nt_lh_sp or None,
|
||||
)
|
||||
_gp2notation.write_notation_sidecar(filepath, _payload)
|
||||
except Exception:
|
||||
|
||||
+524
-29
@@ -18,8 +18,22 @@ plugin is installed; graceful ImportError otherwise with clear message).
|
||||
Public API:
|
||||
is_available() -> bool
|
||||
auto_sync(gp_path, audio_path, ...) -> GpSyncData
|
||||
refine_sync(sync, audio_path, ...) -> GpSyncData
|
||||
estimate_audio_offset(gp_path,
|
||||
audio_path) -> float
|
||||
bar_start_times(gp_path) -> list[float]
|
||||
gp_has_expandable_repeats(gp_path) -> bool
|
||||
build_warp_anchors(sync_points,
|
||||
bar_starts) -> list[tuple[float, float]]
|
||||
warp_time(t, anchors) -> float
|
||||
warp_song_times(song, warp) -> None
|
||||
|
||||
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
|
||||
warp_song_times) are librosa-free: they turn a GpSyncData produced by
|
||||
auto_sync (or extracted from a GP8 file) into a piecewise-linear
|
||||
score-time -> audio-time mapping and apply it to a lib.song.Song, so
|
||||
converted charts follow the recording's actual tempo drift instead of a
|
||||
single scalar offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -353,15 +367,22 @@ def _synthesise_score_chroma(
|
||||
return chroma
|
||||
|
||||
_GP345_TICKS_PER_QUARTER = 960
|
||||
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
|
||||
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
|
||||
# measure starts), so raw beat.start values must be shifted by this origin —
|
||||
# mixing the two axes applied every mid-song tempo change a quarter note late
|
||||
# and skewed the synthesised chroma against the bar timeline.
|
||||
_GP345_TICK_ORIGIN = 960
|
||||
|
||||
|
||||
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
|
||||
|
||||
Seeds with the song's initial tempo at tick 0, then appends every
|
||||
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
|
||||
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
|
||||
synthesis and bar-time computation so both use one identical tempo
|
||||
model (mirrors ``gp2rs._build_tempo_map``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -371,7 +392,10 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
events.append((beat.start, float(mtc.tempo.value)))
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -513,13 +538,75 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
def _gpif_bar_starts(root: ET.Element) -> list[float]:
|
||||
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
|
||||
|
||||
Integrates bar durations from the bar-resolution tempo map and each
|
||||
masterbar's time signature — the same time model _synthesise_score_chroma
|
||||
uses, so bar times land where the bars sit in the synthesised chroma.
|
||||
"""
|
||||
tempo_map = _get_tempo_map(root)
|
||||
masterbars = _children(root, 'MasterBars')
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts: list[float] = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
return bar_starts
|
||||
|
||||
|
||||
def _gp345_measure_start_ticks(song) -> list[int]:
|
||||
"""Cumulative start tick of each measure in a PyGuitarPro song."""
|
||||
starts: list[int] = []
|
||||
cum = 0
|
||||
for mh in song.measureHeaders:
|
||||
starts.append(cum)
|
||||
ts = mh.timeSignature
|
||||
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
return starts
|
||||
|
||||
|
||||
def _extract_sync_points(
|
||||
wp: 'np.ndarray',
|
||||
root: ET.Element,
|
||||
@@ -565,22 +652,7 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts_score = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts_score.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -663,6 +735,224 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
|
||||
|
||||
# ── Audio offset estimation ───────────────────────────────────────────────────
|
||||
|
||||
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
|
||||
#
|
||||
# auto_sync's per-bar sync points describe where each sampled bar of the tab
|
||||
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
|
||||
# assumes the recording holds the authored tempo for the whole song — any
|
||||
# drift accumulates. These helpers build the full piecewise-linear
|
||||
# score-time -> audio-time mapping and apply it to a converted Song, so the
|
||||
# chart follows the recording bar by bar (Songsterr-style sync).
|
||||
|
||||
def bar_start_times(gp_path: str) -> list[float]:
|
||||
"""Score-time (seconds) at the start of every bar of a GP file.
|
||||
|
||||
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
|
||||
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
|
||||
the returned times share an axis with auto_sync's sync points.
|
||||
|
||||
Raises ValueError if the file cannot be parsed, ImportError if the file
|
||||
is GP3/4/5 and PyGuitarPro is not installed.
|
||||
"""
|
||||
try:
|
||||
root = _load_gpif(gp_path)
|
||||
except _Gp345FileError:
|
||||
import guitarpro
|
||||
try:
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
|
||||
tempo_events = _gp345_tempo_events(song)
|
||||
return [
|
||||
_gp345_tick_to_secs(tempo_events, tick)
|
||||
for tick in _gp345_measure_start_ticks(song)
|
||||
]
|
||||
return _gpif_bar_starts(root)
|
||||
|
||||
|
||||
def gp_has_expandable_repeats(gp_path: str) -> bool:
|
||||
"""True when converting `gp_path` expands repeats into a longer timeline
|
||||
than the as-written score auto_sync aligned against.
|
||||
|
||||
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
|
||||
voltas, D.S./D.C. directions), so a file using any of those produces an
|
||||
as-performed timeline that auto_sync's as-written sync points cannot be
|
||||
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
|
||||
so those files always return False — both sides share one bar order.
|
||||
|
||||
Returns False when the file cannot be parsed (callers fall back to
|
||||
offset-only sync on parse failure anyway).
|
||||
"""
|
||||
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
|
||||
return False
|
||||
try:
|
||||
import guitarpro
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception:
|
||||
return False
|
||||
for mh in song.measureHeaders:
|
||||
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
|
||||
return True
|
||||
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
|
||||
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
|
||||
# needs no target marker, so checking `direction` alone would miss
|
||||
# it while gp2rs's playback walker still expands the jump.
|
||||
if (getattr(mh, 'direction', None) is not None
|
||||
or getattr(mh, 'fromDirection', None) is not None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_warp_anchors(
|
||||
sync_points: list[SyncPoint],
|
||||
bar_starts: list[float],
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
|
||||
|
||||
Drops points whose bar index is out of range, points that would break
|
||||
strict monotonicity on either axis (DTW can locally fold on noisy audio;
|
||||
a non-monotonic anchor would make the warp non-invertible and reorder
|
||||
notes), and points whose segment slope implies a physically implausible
|
||||
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
|
||||
usable anchors remain — callers should fall back to scalar-offset sync
|
||||
in that case.
|
||||
"""
|
||||
anchors: list[tuple[float, float]] = []
|
||||
for sp in sorted(sync_points, key=lambda p: p.bar):
|
||||
if not 0 <= sp.bar < len(bar_starts):
|
||||
continue
|
||||
score_t = bar_starts[sp.bar]
|
||||
audio_t = float(sp.time_secs)
|
||||
if anchors and (score_t <= anchors[-1][0] + 1e-6
|
||||
or audio_t <= anchors[-1][1] + 1e-3):
|
||||
continue
|
||||
if anchors:
|
||||
# Slope sanity gate: a segment whose audio/score tempo ratio is
|
||||
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
|
||||
# repeated section, an abridged recording, or a run of
|
||||
# monotonicity-clamped refine points. Keeping it would crush (or
|
||||
# absurdly stretch) every bar in the span, which is far worse
|
||||
# than interpolating through from the neighbouring anchors.
|
||||
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
|
||||
if not 0.2 <= slope <= 5.0:
|
||||
continue
|
||||
anchors.append((score_t, audio_t))
|
||||
return anchors if len(anchors) >= 2 else []
|
||||
|
||||
|
||||
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
|
||||
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
|
||||
|
||||
Between anchors: linear interpolation. Outside the anchor range: the
|
||||
nearest segment's slope is extended, so a count-in before bar 1 and the
|
||||
tail after the last sampled bar keep the local tempo ratio.
|
||||
|
||||
`anchors` must be the >=2-point strictly-monotonic list produced by
|
||||
build_warp_anchors.
|
||||
"""
|
||||
lo = 0
|
||||
hi = len(anchors) - 1
|
||||
if t <= anchors[0][0]:
|
||||
seg = (anchors[0], anchors[1])
|
||||
elif t >= anchors[hi][0]:
|
||||
seg = (anchors[hi - 1], anchors[hi])
|
||||
else:
|
||||
# Binary search for the segment containing t
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if anchors[mid][0] <= t:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
seg = (anchors[lo], anchors[hi])
|
||||
(s0, a0), (s1, a1) = seg
|
||||
slope = (a1 - a0) / (s1 - s0)
|
||||
return a0 + (t - s0) * slope
|
||||
|
||||
|
||||
def warp_song_times(song, warp) -> None:
|
||||
"""Apply a monotonic time-mapping callable to every absolute time in a
|
||||
lib.song.Song, in place.
|
||||
|
||||
Covers beats, sections, song_length, and per-arrangement notes (onset +
|
||||
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
|
||||
difficulty levels, tone changes, and tempo overrides. Durations (note
|
||||
sustain, handshape span) are warped as end-start so they stretch with the
|
||||
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
|
||||
relative to the note onset) are left untouched.
|
||||
|
||||
Duck-typed: accepts any object with the lib.song.Song surface.
|
||||
|
||||
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
|
||||
HandShape objects between the flat arrangement lists and the
|
||||
max-difficulty phrase level, so each object is warped at most once no
|
||||
matter how many containers reference it.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
|
||||
def _once(obj) -> bool:
|
||||
key = id(obj)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
return True
|
||||
|
||||
def _warp_notes(notes):
|
||||
for n in notes or []:
|
||||
if not _once(n):
|
||||
continue
|
||||
end = warp(n.time + n.sustain)
|
||||
n.time = warp(n.time)
|
||||
n.sustain = max(0.0, end - n.time)
|
||||
|
||||
def _warp_chords(chords):
|
||||
for c in chords or []:
|
||||
if not _once(c):
|
||||
continue
|
||||
c.time = warp(c.time)
|
||||
_warp_notes(c.notes)
|
||||
|
||||
def _warp_anchors(anchors):
|
||||
for a in anchors or []:
|
||||
if _once(a):
|
||||
a.time = warp(a.time)
|
||||
|
||||
def _warp_handshapes(shapes):
|
||||
for h in shapes or []:
|
||||
if not _once(h):
|
||||
continue
|
||||
start = warp(h.start_time)
|
||||
end = warp(h.end_time)
|
||||
h.start_time = start
|
||||
h.end_time = max(start, end)
|
||||
|
||||
song.song_length = max(0.0, warp(song.song_length))
|
||||
for b in song.beats:
|
||||
b.time = warp(b.time)
|
||||
for s in song.sections:
|
||||
s.start_time = warp(s.start_time)
|
||||
for arr in song.arrangements:
|
||||
_warp_notes(arr.notes)
|
||||
_warp_chords(arr.chords)
|
||||
_warp_anchors(arr.anchors)
|
||||
_warp_handshapes(arr.hand_shapes)
|
||||
for ph in arr.phrases or []:
|
||||
ph.start_time = warp(ph.start_time)
|
||||
ph.end_time = warp(ph.end_time)
|
||||
for lvl in ph.levels or []:
|
||||
_warp_notes(lvl.notes)
|
||||
_warp_chords(lvl.chords)
|
||||
_warp_anchors(lvl.anchors)
|
||||
_warp_handshapes(lvl.hand_shapes)
|
||||
if arr.tones and isinstance(arr.tones, dict):
|
||||
for change in arr.tones.get('changes') or []:
|
||||
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
|
||||
change['t'] = warp(float(change['t']))
|
||||
for tempo_ev in arr.tempos or []:
|
||||
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
|
||||
tempo_ev['time'] = warp(float(tempo_ev['time']))
|
||||
|
||||
|
||||
def _estimate_audio_offset(
|
||||
root: ET.Element,
|
||||
audio_path: str,
|
||||
@@ -932,12 +1222,7 @@ def auto_sync(
|
||||
# below line up with the chroma timeline.
|
||||
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
|
||||
# Convert tick events to bar events using actual measure start ticks
|
||||
_measure_starts = [] # cumulative tick at start of each bar
|
||||
_cum = 0
|
||||
for _mh2 in _gp345x_song.measureHeaders:
|
||||
_measure_starts.append(_cum)
|
||||
_ts = _mh2.timeSignature
|
||||
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1024,6 +1309,216 @@ def auto_sync(
|
||||
sync_points=sync_points,
|
||||
)
|
||||
|
||||
def refine_sync(
|
||||
sync: GpSyncData,
|
||||
audio_path: str,
|
||||
bars_per_point: int = 8,
|
||||
gp_path: str | None = None,
|
||||
sr: int = _SR,
|
||||
search_radius: float = 0.35,
|
||||
phase_step: float = 0.005,
|
||||
onset_tolerance: float = 0.05,
|
||||
) -> GpSyncData:
|
||||
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
|
||||
|
||||
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
|
||||
the default hop). This pass re-times a denser grid of bars — every
|
||||
`bars_per_point`-th bar plus the first and last — by sweeping a local
|
||||
beat grid (±`search_radius`s in `phase_step` steps) against detected
|
||||
onsets and keeping the phase that aligns best, narrowing each kept point
|
||||
to roughly the phase-step resolution on percussive material.
|
||||
|
||||
Args:
|
||||
sync: Coarse sync data from auto_sync (or a prior refine).
|
||||
audio_path: The same audio file auto_sync aligned against.
|
||||
bars_per_point: Refined-point density; every Nth bar gets a point.
|
||||
gp_path: Optional path to the GP file. When given, exact
|
||||
per-bar score times (bar_start_times) drive the
|
||||
densified grid; without it the grid is limited to
|
||||
a 4/4 approximation built from the points' authored
|
||||
tempos, and accuracy degrades on odd meters.
|
||||
sr: Analysis sample rate.
|
||||
search_radius: ±seconds around each coarse estimate to sweep.
|
||||
phase_step: Sweep resolution in seconds.
|
||||
onset_tolerance: Max onset-to-click distance that counts as aligned.
|
||||
|
||||
Returns:
|
||||
A new GpSyncData with the refined (and usually denser) points and a
|
||||
recomputed audio_offset. Returns `sync` unchanged when it has no
|
||||
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
|
||||
coarse interpolated time rather than locking onto noise.
|
||||
"""
|
||||
if not sync.sync_points:
|
||||
return sync
|
||||
|
||||
pts = sorted(sync.sync_points, key=lambda p: p.bar)
|
||||
|
||||
bar_starts: list[float] | None = None
|
||||
if gp_path:
|
||||
try:
|
||||
bar_starts = bar_start_times(gp_path)
|
||||
except Exception as exc:
|
||||
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
|
||||
"falling back to 4/4 tempo model", gp_path, exc)
|
||||
if bar_starts is None:
|
||||
# Approximate score bar starts from the points' authored tempos,
|
||||
# assuming 4 beats per bar (all GpSyncData carries without the file).
|
||||
max_bar = pts[-1].bar
|
||||
bar_starts = [0.0]
|
||||
ti = 0
|
||||
cur_bpm = pts[0].original_tempo or 120.0
|
||||
for b in range(1, max_bar + 1):
|
||||
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
|
||||
ti += 1
|
||||
cur_bpm = pts[ti].original_tempo or cur_bpm
|
||||
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
|
||||
|
||||
anchors = build_warp_anchors(pts, bar_starts)
|
||||
if len(anchors) < 2:
|
||||
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
|
||||
"input unchanged")
|
||||
return sync
|
||||
|
||||
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
|
||||
# boundary semantics can't drift from the rest of the module.
|
||||
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
|
||||
|
||||
def _orig_bpm_at(bar: int) -> float:
|
||||
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
|
||||
|
||||
n_bars = len(bar_starts)
|
||||
step = max(1, int(bars_per_point))
|
||||
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
|
||||
|
||||
# Deferred past the pure early-return paths above so degenerate inputs
|
||||
# (no points, <2 anchors) resolve without librosa installed.
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, _ = librosa.load(audio_path, sr=sr, mono=True)
|
||||
audio_dur = len(y) / sr
|
||||
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
|
||||
onset_frames = librosa.onset.onset_detect(
|
||||
y=y, sr=sr, hop_length=hop, backtrack=True
|
||||
)
|
||||
onset_times = np.asarray(
|
||||
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
|
||||
)
|
||||
|
||||
refined: list[tuple[int, float]] = []
|
||||
for b in targets:
|
||||
score_t = bar_starts[b]
|
||||
coarse = warp_time(score_t, anchors)
|
||||
if coarse > audio_dur + 1.0:
|
||||
break # bar falls past the end of the recording
|
||||
# Local beat period in AUDIO time: authored beat period scaled by the
|
||||
# local warp slope (recording tempo / authored tempo around this bar).
|
||||
slope = warp_time(score_t + 1.0, anchors) - coarse
|
||||
slope = min(max(slope, 0.25), 4.0)
|
||||
beat_period = (60.0 / _orig_bpm_at(b)) * slope
|
||||
|
||||
# Keep the scoring grid short: beat_period is estimated from the
|
||||
# coarse anchors (a few % off), and grid drift grows linearly with
|
||||
# distance — 16 beats at 2% error is already ~150ms of skew at the
|
||||
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
|
||||
grid_span = 8 * beat_period
|
||||
# Clamp the sweep window below half a beat so the neighbouring beat
|
||||
# is never a candidate — on periodic material (steady drums) a grid
|
||||
# shifted by one whole beat scores identically and the sweep could
|
||||
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
|
||||
# this window still covers at all but extreme tempos.
|
||||
radius = min(search_radius, 0.45 * beat_period)
|
||||
w_lo = coarse - radius - onset_tolerance
|
||||
w_hi = coarse + radius + grid_span + onset_tolerance
|
||||
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
|
||||
if len(local) < 4:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
best_t, best_score, best_dist = coarse, -1, 0.0
|
||||
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
|
||||
phase_step):
|
||||
clicks = np.arange(phase, phase + grid_span, beat_period)
|
||||
score = int(sum(
|
||||
1 for t in local
|
||||
if float(np.min(np.abs(clicks - t))) < onset_tolerance
|
||||
))
|
||||
dist = abs(float(phase) - coarse)
|
||||
# Ties break toward the coarse estimate so a flat score surface
|
||||
# (sustained pads, sparse onsets) can't drag the point sideways.
|
||||
if score > best_score or (score == best_score and dist < best_dist):
|
||||
best_score, best_t, best_dist = score, float(phase), dist
|
||||
|
||||
# A sweep that matched almost nothing found a spurious edge
|
||||
# alignment, not the beat grid — this happens when the true phase
|
||||
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
|
||||
# where the DTW coarse error exceeds half a beat. Keeping the
|
||||
# coarse estimate degrades gracefully instead of locking a
|
||||
# fraction of a beat off.
|
||||
if best_score < 3:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
# The onset-count score is flat within ±onset_tolerance of the true
|
||||
# phase, so the sweep alone can be off by up to the tolerance. Snap
|
||||
# inside that plateau: shift by the median residual between matched
|
||||
# onsets and their nearest grid click. Only the first few beats
|
||||
# count here — they are nearly insensitive to beat_period error,
|
||||
# while far clicks would leak that error into the residuals.
|
||||
if best_score > 0:
|
||||
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
|
||||
beat_period)
|
||||
residuals = []
|
||||
for t in local:
|
||||
d = clicks - float(t)
|
||||
j = int(np.argmin(np.abs(d)))
|
||||
if abs(d[j]) < onset_tolerance:
|
||||
residuals.append(-float(d[j])) # onset minus click
|
||||
if residuals:
|
||||
best_t += float(np.median(residuals))
|
||||
refined.append((b, best_t))
|
||||
|
||||
if not refined:
|
||||
return sync
|
||||
|
||||
# Enforce monotonicity: a point refined earlier than its predecessor
|
||||
# would fold the warp. Clamp to a small positive gap.
|
||||
mono: list[tuple[int, float]] = []
|
||||
prev_t: float | None = None
|
||||
for b, t in refined:
|
||||
t = max(t, 0.0)
|
||||
if prev_t is not None and t <= prev_t + 0.02:
|
||||
t = prev_t + 0.02
|
||||
mono.append((b, t))
|
||||
prev_t = t
|
||||
|
||||
# Recompute per-segment modified tempos from the refined times (same
|
||||
# formula _extract_sync_points uses; the last point carries the previous
|
||||
# segment's tempo forward).
|
||||
new_points: list[SyncPoint] = []
|
||||
for i, (b, t) in enumerate(mono):
|
||||
obpm = _orig_bpm_at(b)
|
||||
if i + 1 < len(mono):
|
||||
b2, t2 = mono[i + 1]
|
||||
score_seg = bar_starts[b2] - bar_starts[b]
|
||||
audio_seg = t2 - t
|
||||
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
|
||||
mod = max(20.0, min(300.0, mod))
|
||||
else:
|
||||
mod = new_points[-1].modified_tempo if new_points else obpm
|
||||
new_points.append(SyncPoint(
|
||||
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
|
||||
))
|
||||
|
||||
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
|
||||
len(new_points), len(pts), -new_points[0].time_secs)
|
||||
return GpSyncData(
|
||||
audio_offset=-new_points[0].time_secs,
|
||||
audio_asset_id=sync.audio_asset_id,
|
||||
sync_points=new_points,
|
||||
)
|
||||
|
||||
|
||||
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
|
||||
"""
|
||||
Estimate the audio_offset for a GP file aligned to an audio file.
|
||||
|
||||
@@ -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)
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
"""Text-matching engine for MusicBrainz metadata enrichment (P8).
|
||||
|
||||
Pure functions only — no network, no database, no server imports — so the
|
||||
whole matching pipeline is unit-testable in isolation. server.py owns the
|
||||
throttled HTTP transport and the song_enrichment writes; this module owns:
|
||||
|
||||
* denoise/tokenize: fold community chart-title noise (author suffixes,
|
||||
``(440Hz)``/``(Live)``/``(No Lead)``/``(v2)`` parentheticals, punctuation,
|
||||
diacritics, ``AC DC``/``ACDC``/``AC/DC`` spelling drift) into a comparable
|
||||
token form,
|
||||
* similarity + scoring: token-set similarity on artist+title with year and
|
||||
duration proximity as corroborating bonuses,
|
||||
* tier classification: auto (high) / review (medium) / none (low) — the
|
||||
design rule is that a WRONG match is worse than no match, so the auto
|
||||
tier is deliberately strict and medium confidence goes to a human,
|
||||
* MusicBrainz JSON parsing: normalize ``/ws/2`` recording documents into
|
||||
the flat candidate dicts the review UI and song_enrichment store.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# ── Tier thresholds ───────────────────────────────────────────────────────────
|
||||
# Combined score = 0.5*artist_sim + 0.5*title_sim + corroboration bonuses
|
||||
# (capped at 1.0). Wrong-match is worse than slow (design §5), so `auto`
|
||||
# additionally requires BOTH fields to individually agree — a perfect title
|
||||
# with a mismatched artist (a cover) must never auto-canonicalize, whatever
|
||||
# the combined threshold is set to. AUTO_MIN is only the DEFAULT: the host
|
||||
# surfaces it as the user-configurable "auto-apply confidence" setting and
|
||||
# passes the chosen value into classify(auto_min=…).
|
||||
AUTO_MIN = 0.90
|
||||
AUTO_ARTIST_MIN = 0.8
|
||||
AUTO_TITLE_MIN = 0.6
|
||||
REVIEW_MIN = 0.65
|
||||
|
||||
YEAR_BONUS = 0.05 # candidate year within ±1 of the chart's year
|
||||
DURATION_BONUS = 0.05 # candidate length within 5s of the chart's audio
|
||||
DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
||||
_DURATION_TIGHT = 5
|
||||
_DURATION_LOOSE = 15
|
||||
|
||||
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||
# studio album for display and to reward studio recordings in ranking.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||
# performance qualifiers) or when it reads as an author credit ("by X",
|
||||
# "charted by X"). Both sides of a comparison are denoised symmetrically, so
|
||||
# over-stripping a meaningful group costs a little precision but never
|
||||
# produces an asymmetric mismatch.
|
||||
_NOISE_TERMS = (
|
||||
r"440\s*hz", r"a440", r"432\s*hz",
|
||||
r"live", r"acoustic", r"instrumental",
|
||||
r"no\s+(?:lead|rhythm|bass|vocals?|drums)",
|
||||
r"(?:lead|rhythm|bass)\s+only",
|
||||
r"v\d+", r"ver(?:sion)?\s*\d+",
|
||||
r"remaster(?:ed)?(?:\s*\d{4})?", r"re-?recorded?",
|
||||
r"fix(?:ed)?", r"updated?",
|
||||
r"bonus", r"custom",
|
||||
)
|
||||
_NOISE_GROUP_RE = re.compile(
|
||||
r"[(\[][^)\]]*\b(?:" + "|".join(_NOISE_TERMS) + r")\b[^)\]]*[)\]]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Author credits: "(by SomeCharter)", "[charted by X]", "(chart by X)".
|
||||
_AUTHOR_GROUP_RE = re.compile(
|
||||
r"[(\[]\s*(?:chart(?:ed)?\s+)?by\s+[^)\]]*[)\]]", re.IGNORECASE)
|
||||
# Trailing "- by SomeCharter" outside parens.
|
||||
_AUTHOR_TAIL_RE = re.compile(r"\s+-\s+(?:chart(?:ed)?\s+)?by\s+.+$", re.IGNORECASE)
|
||||
|
||||
_PUNCT_RE = re.compile(r"[^\w\s]|_")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _strip_diacritics(s: str) -> str:
|
||||
return "".join(
|
||||
ch for ch in unicodedata.normalize("NFKD", s)
|
||||
if not unicodedata.combining(ch)
|
||||
)
|
||||
|
||||
|
||||
def denoise(s, *, strip_leading_the: bool = False) -> str:
|
||||
"""Fold a community metadata string into its comparable form:
|
||||
lowercase, diacritics stripped, noise parentheticals and author credits
|
||||
removed, punctuation collapsed to spaces. ``strip_leading_the`` drops a
|
||||
leading "The " — used for ARTIST comparison only ("The Beatles" ==
|
||||
"Beatles"), never titles ("The Trooper" must keep its "the")."""
|
||||
s = str(s or "")
|
||||
s = _NOISE_GROUP_RE.sub(" ", s)
|
||||
s = _AUTHOR_GROUP_RE.sub(" ", s)
|
||||
s = _AUTHOR_TAIL_RE.sub(" ", s)
|
||||
s = _strip_diacritics(s).casefold()
|
||||
s = s.replace("&", " and ")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
s = _WS_RE.sub(" ", s).strip()
|
||||
if strip_leading_the and s.startswith("the "):
|
||||
s = s[4:]
|
||||
return s
|
||||
|
||||
|
||||
def tokens(s, **kw) -> list[str]:
|
||||
d = denoise(s, **kw)
|
||||
return d.split() if d else []
|
||||
|
||||
|
||||
def _compact(toks: list[str]) -> str:
|
||||
return "".join(toks)
|
||||
|
||||
|
||||
def similarity(a, b, *, artist: bool = False) -> float:
|
||||
"""Token-set similarity in [0, 1]. Dice coefficient over the denoised
|
||||
token sets, with a compacted-string equality fold so spelling drift that
|
||||
only moves token boundaries ("ACDC" / "AC DC" / "AC/DC", "Greenday" /
|
||||
"Green Day") counts as identical."""
|
||||
kw = {"strip_leading_the": artist}
|
||||
ta, tb = tokens(a, **kw), tokens(b, **kw)
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
if _compact(ta) == _compact(tb):
|
||||
return 1.0
|
||||
sa, sb = set(ta), set(tb)
|
||||
return 2.0 * len(sa & sb) / (len(sa) + len(sb))
|
||||
|
||||
|
||||
def _year_int(v):
|
||||
try:
|
||||
y = int(str(v)[:4])
|
||||
return y if y > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _duration_int(v):
|
||||
try:
|
||||
d = int(round(float(v)))
|
||||
return d if d > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def cand_artist_sim(song: dict, cand: dict) -> float:
|
||||
"""Best artist similarity between the song's reference artist and the
|
||||
candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate
|
||||
names). MusicBrainz stores many artists under a non-Latin primary name
|
||||
(大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a
|
||||
reference typed/derived in romaji scores 0 against the primary but 1.0
|
||||
against the alias. The caller (server) attaches `artist_aliases` only for
|
||||
promising near-misses, so this is a plain max when they're present and the
|
||||
original single comparison when they're not."""
|
||||
best = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
for alias in cand.get("artist_aliases") or []:
|
||||
if best >= 1.0:
|
||||
break
|
||||
s = similarity(song.get("artist"), alias, artist=True)
|
||||
if s > best:
|
||||
best = s
|
||||
return best
|
||||
|
||||
|
||||
def score_candidate(song: dict, cand: dict) -> float:
|
||||
"""Combined confidence that MusicBrainz candidate `cand` is the song the
|
||||
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
|
||||
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
|
||||
half — classify() separately refuses to auto-match without both."""
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
score = 0.5 * artist_sim + 0.5 * title_sim
|
||||
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
|
||||
if sy and cy and abs(sy - cy) <= 1:
|
||||
score += YEAR_BONUS
|
||||
sd, cd = _duration_int(song.get("duration")), _duration_int(cand.get("duration"))
|
||||
if sd and cd:
|
||||
diff = abs(sd - cd)
|
||||
if diff <= _DURATION_TIGHT:
|
||||
score += DURATION_BONUS
|
||||
elif diff <= _DURATION_LOOSE:
|
||||
score += DURATION_BONUS_LOOSE
|
||||
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
def classify(song: dict, cand: dict, score: float, auto_min: float | None = None) -> str:
|
||||
"""Tier for a scored candidate: 'auto' | 'review' | 'none'.
|
||||
|
||||
`auto` (tier-2) needs the combined score AND per-field agreement AND
|
||||
both fields present — a perfect-title/wrong-artist cover, or a chart
|
||||
with no artist at all, is at best a review item, never an auto match.
|
||||
`auto_min` overrides the default combined-score threshold (the user's
|
||||
"auto-apply confidence" setting); the per-field floors always apply.
|
||||
"""
|
||||
if auto_min is None:
|
||||
auto_min = AUTO_MIN
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
|
||||
and title_sim >= AUTO_TITLE_MIN):
|
||||
return "auto"
|
||||
if score >= REVIEW_MIN:
|
||||
return "review"
|
||||
return "none"
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""Score every candidate against the song and return them sorted best-first.
|
||||
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||
the caller knows the audio length, the duration match break the tie so the
|
||||
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
sd = _duration_int(song.get("duration"))
|
||||
# For a chart that IS a live take (build_recording_query keeps live
|
||||
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||
# live version instead of auto-matching the studio one.
|
||||
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||
|
||||
def _dur_diff(c):
|
||||
cd = _duration_int(c.get("duration"))
|
||||
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||
|
||||
ranked = []
|
||||
for cand in candidates or []:
|
||||
c = dict(cand)
|
||||
c["score"] = round(score_candidate(song, cand), 4)
|
||||
ranked.append(c)
|
||||
ranked.sort(
|
||||
key=lambda c: (c["score"],
|
||||
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||
-_dur_diff(c), # closest to the audio length
|
||||
c.get("mb_score") or 0),
|
||||
reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
# ── MusicBrainz query + response parsing ──────────────────────────────────────
|
||||
|
||||
def _lucene_escape_phrase(s: str) -> str:
|
||||
"""Escape a string for use inside a quoted Lucene phrase."""
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_recording_query(artist, title, *, loose: bool = False) -> str:
|
||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||
poison the search server's own scoring.
|
||||
|
||||
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
|
||||
term groups (``(telephone number) AND (junko ohashi)``). The point:
|
||||
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
|
||||
*primary* artist name — it never searches ALIASES — so a recording stored
|
||||
under a non-Latin primary (大橋純子) whose romanized name is only an alias
|
||||
is invisible to the strict query. A loose term query searches the whole
|
||||
document, aliases included, and surfaces it. Lower precision by design: it
|
||||
is a FALLBACK for when the strict query returns nothing, and its results
|
||||
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
|
||||
per-field floors), so noise never auto-applies."""
|
||||
t = denoise(title)
|
||||
a = denoise(artist)
|
||||
if loose:
|
||||
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
|
||||
# (punctuation → spaces, diacritics stripped, & → "and"), so no
|
||||
# Lucene-special character survives to need escaping. Group each field's
|
||||
# terms and require both groups.
|
||||
q = " AND ".join("(%s)" % g for g in (t, a) if g)
|
||||
# Keep the SAME live exclusion as the strict path: the loose query is
|
||||
# lower-precision, and score_candidate doesn't penalize a live take, so
|
||||
# without this a studio chart whose strict query missed could fall back
|
||||
# to — and auto-confirm — a live-only recording. Skipped only when the
|
||||
# source title is itself a live take (mirrors the strict path).
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
parts = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
q = " AND ".join(parts)
|
||||
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||
# take is never tagged Live, and this is the single biggest source of junk in
|
||||
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||
# REUSE the studio recording, so filtering them would drop the very recording
|
||||
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||
# AC/DC studio "Highway to Hell" recording entirely).
|
||||
#
|
||||
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||
# tag — mirroring what denoise removes.
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
|
||||
|
||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
"""(display name, artist mbid, sort name) from an artist-credit array."""
|
||||
credits = doc.get("artist-credit") or []
|
||||
name = ""
|
||||
for part in credits:
|
||||
if isinstance(part, dict):
|
||||
name += str(part.get("name", "")) + str(part.get("joinphrase", "") or "")
|
||||
else: # ws/2 can emit bare join strings in older serializations
|
||||
name += str(part)
|
||||
first = next((p for p in credits if isinstance(p, dict)), None) or {}
|
||||
artist = first.get("artist") or {}
|
||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||
|
||||
|
||||
def _is_clean_studio_album(rg: dict) -> bool:
|
||||
"""A release-group that is a primary-type Album with NO non-canonical
|
||||
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||
if str(rg.get("primary-type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _best_release(doc: dict) -> dict:
|
||||
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
def sort_key(r):
|
||||
rg = r.get("release-group") or {}
|
||||
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
date = str(r.get("date", "") or "9999")
|
||||
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||
# the studio album over an (official) live/comp album for the display
|
||||
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||
return (status_ok, clean, date)
|
||||
|
||||
return sorted(releases, key=sort_key)[0]
|
||||
|
||||
|
||||
def _genres(doc: dict, limit: int = 5) -> list[str]:
|
||||
"""Genre names from a recording doc. Search results carry folksonomy
|
||||
`tags`; lookups with inc=genres carry curated `genres`. Both are
|
||||
[{name, count}] — take the most-voted few."""
|
||||
raw = doc.get("genres") or doc.get("tags") or []
|
||||
entries = [e for e in raw if isinstance(e, dict) and e.get("name")]
|
||||
entries.sort(key=lambda e: e.get("count") or 0, reverse=True)
|
||||
return [str(e["name"]) for e in entries[:limit]]
|
||||
|
||||
|
||||
def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"""Normalize one /ws/2 recording document (search hit or direct lookup)
|
||||
into the flat candidate dict stored in song_enrichment.candidates and
|
||||
rendered by the review drawer. Returns None for malformed docs."""
|
||||
if not isinstance(doc, dict) or not doc.get("id") or not doc.get("title"):
|
||||
return None
|
||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||
release = _best_release(doc)
|
||||
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||
length = doc.get("length")
|
||||
try:
|
||||
duration = int(round(float(length) / 1000.0)) if length else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
isrcs = doc.get("isrcs") or []
|
||||
isrcs = [str(i) for i in isrcs if isinstance(i, (str,))]
|
||||
return {
|
||||
"recording_id": str(doc["id"]),
|
||||
"title": str(doc.get("title", "")),
|
||||
"artist": artist_name,
|
||||
"artist_id": artist_id,
|
||||
"artist_sort": artist_sort,
|
||||
"release_id": str(release.get("id", "") or ""),
|
||||
"album": str(release.get("title", "") or ""),
|
||||
"year": str(release.get("date", "") or "")[:4],
|
||||
"duration": duration,
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
def parse_search_response(body: dict) -> list[dict]:
|
||||
"""Candidates from a /ws/2/recording search response."""
|
||||
docs = (body or {}).get("recordings") or []
|
||||
out = []
|
||||
for doc in docs:
|
||||
cand = parse_recording_doc(doc)
|
||||
if cand:
|
||||
out.append(cand)
|
||||
return out
|
||||
+160
-7
@@ -203,7 +203,13 @@ def convert_midi_track_to_keys_wire(
|
||||
# a foreign track's tempo events do NOT apply to the chosen
|
||||
# track. Merging would mis-time the notes — restrict the tempo
|
||||
# scan to the selected track only.
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
|
||||
# division (mido returns the signed short as-is). Both feed the two
|
||||
# divisions below (tempo-table build + tick_to_seconds), so guard here:
|
||||
# 0 would raise ZeroDivisionError and a negative value would yield
|
||||
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
|
||||
# case also falls back to the SMF default.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -352,7 +358,14 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
- type 1: parallel tracks share the timeline; merge tempo events.
|
||||
- type 2: independent timelines; tempo only from the chosen track.
|
||||
"""
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
# A metrical header carries positive ticks-per-beat. mido reads the SMF
|
||||
# division as a signed short, so an SMPTE-division file surfaces as a
|
||||
# negative value and a malformed header as 0 — both make the two division
|
||||
# sites below divide by a non-positive number (ZeroDivisionError, or
|
||||
# negative seconds that send the bar walk off the rails). Fall back to the
|
||||
# SMF default here, the single place every caller routes ticks through, so
|
||||
# each caller's own fallback is real rather than cosmetic.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -393,6 +406,141 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
return tick_to_seconds
|
||||
|
||||
|
||||
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
|
||||
# trailing meta) could otherwise imply millions of bars. Real charts sit
|
||||
# orders of magnitude below this.
|
||||
_TEMPO_MAP_MAX_BARS = 20000
|
||||
|
||||
|
||||
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
|
||||
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
|
||||
signatures, and a full beat grid — the data the note converters here
|
||||
always computed internally (to bake note times) and then threw away,
|
||||
which left every MIDI import with no bars, no measures, and an implied
|
||||
4/4 no matter what the file said.
|
||||
|
||||
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
|
||||
|
||||
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
|
||||
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
|
||||
the song-timeline sidecar shape (feedpak-spec §7.4).
|
||||
- ``beats``: one row per beat on the editor grid shape — downbeats carry
|
||||
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
|
||||
denominator), interior beats carry ``measure: -1``. The beat unit
|
||||
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
|
||||
|
||||
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
|
||||
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
|
||||
(independent timelines — callers must never share one grid across
|
||||
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
|
||||
file places one mid-bar (ill-formed but seen in the wild). All times
|
||||
are computed from absolute ticks through the cumulative tempo table and
|
||||
rounded once at emit — rounding error never accumulates with song
|
||||
length. An SMF with no note events yields empty ``beats``.
|
||||
"""
|
||||
midi = mido.MidiFile(midi_path)
|
||||
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
|
||||
# read as a signed short) otherwise — fall back so beat_ticks below stays
|
||||
# sane, mirroring the guard inside _build_tick_to_seconds.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
# Same scope both converters use: type 2 reads only the chosen track
|
||||
# (independent timelines); type 0/1 merge all tracks (shared timeline).
|
||||
source_tracks = (
|
||||
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
|
||||
)
|
||||
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
|
||||
|
||||
# ── collect meta + the end of musical content in one pass ────────────
|
||||
sig_events: list[tuple[int, int, int]] = []
|
||||
tempo_events: list[tuple[int, int]] = []
|
||||
end_tick = 0
|
||||
for tr in source_tracks:
|
||||
abs_tick = 0
|
||||
for msg in tr:
|
||||
abs_tick += msg.time
|
||||
if msg.type == "time_signature":
|
||||
num = int(getattr(msg, "numerator", 4) or 4)
|
||||
den = int(getattr(msg, "denominator", 4) or 4)
|
||||
if num > 0 and den > 0:
|
||||
sig_events.append((abs_tick, num, den))
|
||||
elif msg.type == "set_tempo":
|
||||
tempo_events.append((abs_tick, int(msg.tempo)))
|
||||
elif msg.type in ("note_on", "note_off"):
|
||||
end_tick = max(end_tick, abs_tick)
|
||||
|
||||
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
|
||||
sig_events.sort(key=lambda e: e[0])
|
||||
sigs: list[tuple[int, int, int]] = []
|
||||
for ev in sig_events:
|
||||
if sigs and sigs[-1][0] == ev[0]:
|
||||
sigs[-1] = ev
|
||||
else:
|
||||
sigs.append(ev)
|
||||
if not sigs or sigs[0][0] > 0:
|
||||
sigs.insert(0, (0, 4, 4))
|
||||
|
||||
tempo_events.sort(key=lambda e: e[0])
|
||||
seen_tempo_ticks: dict[int, int] = {}
|
||||
for ev_tick, ev_tempo in tempo_events:
|
||||
seen_tempo_ticks[ev_tick] = ev_tempo
|
||||
sorted_tempo_ticks = sorted(seen_tempo_ticks)
|
||||
tempos_out: list[dict] = []
|
||||
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
|
||||
# lands after the start (or there are none). The beat grid already runs
|
||||
# at 120 for the head of the song, so the sidecar must say so too —
|
||||
# symmetric with the (0, 4, 4) default seeded into the signatures above.
|
||||
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
|
||||
tempos_out.append({"time": 0.0, "bpm": 120.0})
|
||||
for ev_tick in sorted_tempo_ticks:
|
||||
tempos_out.append({
|
||||
"time": round(tick_to_seconds(ev_tick), 3),
|
||||
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
|
||||
})
|
||||
|
||||
time_signatures_out = [
|
||||
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
|
||||
for t, num, den in sigs
|
||||
]
|
||||
|
||||
# ── walk bars from tick 0 to the end of the notes ────────────────────
|
||||
beats: list[dict] = []
|
||||
if end_tick > 0:
|
||||
cur_tick = 0.0
|
||||
measure = 1
|
||||
sig_idx = 0
|
||||
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
|
||||
# Active signature: the latest event at or before this bar's
|
||||
# start. Mid-bar events wait for the next boundary by
|
||||
# construction (we only re-read between bars).
|
||||
while (sig_idx + 1 < len(sigs)
|
||||
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
|
||||
sig_idx += 1
|
||||
_, num, den = sigs[sig_idx]
|
||||
beat_ticks = ticks_per_beat * 4.0 / den
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
|
||||
"measure": measure,
|
||||
"den": den,
|
||||
})
|
||||
for k in range(1, num):
|
||||
sub_tick = cur_tick + k * beat_ticks
|
||||
if sub_tick >= end_tick:
|
||||
break
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
|
||||
"measure": -1,
|
||||
})
|
||||
cur_tick += num * beat_ticks
|
||||
measure += 1
|
||||
|
||||
return {
|
||||
"tempos": tempos_out,
|
||||
"time_signatures": time_signatures_out,
|
||||
"beats": beats,
|
||||
}
|
||||
|
||||
|
||||
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
||||
|
||||
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
||||
@@ -486,10 +634,12 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -527,10 +677,13 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": []})
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
+21
-5
@@ -114,11 +114,27 @@ def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
|
||||
pitches = sorted(n["midi"] for n in group)
|
||||
span = pitches[-1] - pitches[0]
|
||||
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
|
||||
# Largest internal gap; ties resolve to the lowest such gap so the
|
||||
# left hand keeps the tight low cluster.
|
||||
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
|
||||
split_after = gaps.index(max(gaps))
|
||||
threshold = pitches[split_after] # lh: midi <= threshold
|
||||
# Prefer middle C as the split boundary when notes straddle it —
|
||||
# this correctly handles bass+treble chords from piano imports where
|
||||
# the largest-gap heuristic picks the wrong split point (e.g.
|
||||
# [G2, E3, C4]: largest gap is G2→E3 but the real split is E3|C4).
|
||||
# BUT only when both resulting hands are themselves playable: a bass
|
||||
# note under a treble voicing that merely dips below C4 (e.g.
|
||||
# [E2, B3, D4, G4]) would otherwise land E2+B3 in one hand — a
|
||||
# 19-semitone span that re-violates HAND_SPLIT_SPAN_SEMITONES. When
|
||||
# the middle-C split produces an unplayable hand, fall back to the
|
||||
# largest internal gap (which correctly isolates E2 there).
|
||||
threshold = None
|
||||
if pitches[0] < MIDDLE_C <= pitches[-1]:
|
||||
_lh = [p for p in pitches if p < MIDDLE_C]
|
||||
_rh = [p for p in pitches if p >= MIDDLE_C]
|
||||
if (_lh[-1] - _lh[0] <= HAND_SPLIT_SPAN_SEMITONES
|
||||
and _rh[-1] - _rh[0] <= HAND_SPLIT_SPAN_SEMITONES):
|
||||
threshold = MIDDLE_C - 1 # lh: midi < MIDDLE_C
|
||||
if threshold is None:
|
||||
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
|
||||
split_after = gaps.index(max(gaps))
|
||||
threshold = pitches[split_after]
|
||||
for n in group:
|
||||
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
|
||||
else:
|
||||
|
||||
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
|
||||
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
|
||||
# raises for an embedded NUL, so the byte would otherwise leak through
|
||||
# containment. An explicit guard is strictly-more-rejection (no effect on
|
||||
# the zip-slip / traversal contract).
|
||||
if "\x00" in name:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
|
||||
+7
-6
@@ -109,13 +109,14 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
|
||||
the root it already resolved; in-process callers can pass the resolver
|
||||
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
|
||||
|
||||
FeedBack reads only its own `.sloppak` format and loose-folder XML
|
||||
songs. Encrypted/proprietary archive formats are not supported and are
|
||||
silently ignored (empty metadata) rather than decrypted.
|
||||
FeedBack reads only its own song-package format (`.feedpak` / legacy
|
||||
`.sloppak`) and loose-folder XML songs. Encrypted/proprietary archive
|
||||
formats are not supported and are silently ignored (empty metadata)
|
||||
rather than decrypted.
|
||||
"""
|
||||
# Sloppak is detected by `.sloppak` suffix only (cheap), so check it
|
||||
# first — that way a user's loose folder named `foo.sloppak` still wins
|
||||
# the sloppak branch instead of being misclassified.
|
||||
# Packages are detected by suffix only (`.feedpak`/`.sloppak`, cheap), so
|
||||
# check that first — that way a user's loose folder named `foo.feedpak`
|
||||
# still wins the package branch instead of being misclassified.
|
||||
if sloppak_mod.is_sloppak(path):
|
||||
return _extract_meta_sloppak(path)
|
||||
if loosefolder_mod.is_loose_song(path):
|
||||
|
||||
+39
-21
@@ -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
|
||||
@@ -703,20 +703,32 @@ def load_song(
|
||||
and isinstance(e.get("d"), (int, float))
|
||||
]
|
||||
if song.lyrics:
|
||||
# Provenance — populated by the converter (xml/notechart),
|
||||
# the WhisperX fallback (whisperx), or hand-edits
|
||||
# (user). Validate against the closed enum so a
|
||||
# hand-edited (or otherwise malformed) manifest can't
|
||||
# propagate a YAML dict / list / arbitrary string
|
||||
# into the highway WS `lyrics.source` field and out
|
||||
# to plugin badges. Anything outside the enum (or
|
||||
# the wrong type) falls back to "xml" — the spec's
|
||||
# back-compat default — instead of being stringified
|
||||
# and trusted.
|
||||
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
|
||||
# Legacy alias: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name; normalise it.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
|
||||
# Provenance. The feedpak spec (§7.1) vocabulary is
|
||||
# {authored, transcribed, user}; older manifests + the
|
||||
# in-tree readers also use the source-format names
|
||||
# (xml/notechart) and the WhisperX engine name
|
||||
# (whisperx). Accept the union so both spec-compliant
|
||||
# writers (e.g. the stem_splitter plugin emitting
|
||||
# `transcribed`) and legacy packs validate. Validate
|
||||
# against the closed enum so a hand-edited (or otherwise
|
||||
# malformed) manifest can't propagate a YAML dict / list /
|
||||
# arbitrary string into the highway WS `lyrics.source`
|
||||
# field and out to plugin badges. Anything outside the
|
||||
# enum (or the wrong type) falls back to "xml" — the
|
||||
# back-compat default — instead of being stringified and
|
||||
# trusted.
|
||||
# Post-alias values only: `whisperx` is normalised to
|
||||
# `transcribed` before the membership check below, so (like
|
||||
# `sng`) it is intentionally absent from this set.
|
||||
_ALLOWED_LYRICS_SOURCES = {
|
||||
"xml", "notechart", "user",
|
||||
"authored", "transcribed",
|
||||
}
|
||||
# Legacy aliases: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name, and the WhisperX
|
||||
# fallback with the engine name — normalise both to the
|
||||
# spec vocabulary the badges now expect.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
|
||||
raw_source = manifest.get("lyrics_source")
|
||||
if isinstance(raw_source, str):
|
||||
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
|
||||
@@ -773,7 +785,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
|
||||
@@ -919,6 +931,12 @@ def extract_meta(path: Path) -> dict:
|
||||
"artist": str(manifest.get("artist", "")),
|
||||
"album": str(manifest.get("album", "")),
|
||||
"year": str(manifest.get("year", "") or ""),
|
||||
# Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary.
|
||||
"genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")),
|
||||
# Album track order from the feedpak `track`/`disc` fields (spec 1.12.0);
|
||||
# None when unauthored (the album view then falls back to title order).
|
||||
"track_number": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("track")),
|
||||
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
|
||||
"duration": float(manifest.get("duration", 0) or 0),
|
||||
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||
"arrangements": arrangements,
|
||||
|
||||
+61
-5
@@ -10,9 +10,9 @@ source of truth, so the change survives both incremental and full rescans.
|
||||
only the keys present are overwritten, so an edit of just the title can't blank
|
||||
out the artist.
|
||||
|
||||
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
|
||||
Unknown / unsupported shapes return False and the caller keeps the DB-only
|
||||
update.
|
||||
Only feedBack's own song-package format (zip- or directory-form, ``.feedpak``
|
||||
or the legacy ``.sloppak`` suffix) is writable. Unknown / unsupported shapes
|
||||
return False and the caller keeps the DB-only update.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -107,19 +107,75 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
|
||||
return _rewrite_zip_manifest(path, dumped)
|
||||
|
||||
|
||||
def gap_fill_sloppak(path: Path, additions: dict) -> bool:
|
||||
"""Append ABSENT top-level keys to a sloppak manifest (the gap-fill
|
||||
contract: user-initiated, adds missing keys only, never replaces
|
||||
anything the author set).
|
||||
|
||||
Unlike ``write_sloppak_metadata`` this does NOT re-serialize the
|
||||
manifest — because every added key is absent by definition, the new
|
||||
lines can simply be appended, so the author's existing bytes (key
|
||||
order, comments, formatting) survive verbatim. Directory form gets a
|
||||
one-time ``manifest.yaml.bak`` + temp + atomic replace; zip form goes
|
||||
through the same backup/temp/replace rewriter the metadata editor
|
||||
uses. Returns True if anything was written; raises ``ValueError`` if
|
||||
a requested key already exists (callers are expected to have checked
|
||||
— this is the last-line never-clobber guard)."""
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
path = Path(path)
|
||||
if not additions:
|
||||
return False
|
||||
manifest = sloppak_mod.load_manifest(path) or {}
|
||||
clash = sorted(k for k in additions if k in manifest)
|
||||
if clash:
|
||||
raise ValueError("gap-fill refused: key(s) already present: " + ", ".join(clash))
|
||||
|
||||
if path.is_dir():
|
||||
mf = path / "manifest.yaml"
|
||||
if not mf.exists() and (path / "manifest.yml").exists():
|
||||
mf = path / "manifest.yml"
|
||||
original = mf.read_text(encoding="utf-8")
|
||||
else:
|
||||
with zipfile.ZipFile(str(path), "r") as zin:
|
||||
names = zin.namelist()
|
||||
manifest_name = "manifest.yaml"
|
||||
for cand in ("manifest.yaml", "manifest.yml"):
|
||||
if cand in names:
|
||||
manifest_name = cand
|
||||
break
|
||||
original = zin.read(manifest_name).decode("utf-8")
|
||||
|
||||
appended = original if original.endswith("\n") or not original else original + "\n"
|
||||
appended += yaml.safe_dump(additions, sort_keys=False, allow_unicode=True)
|
||||
|
||||
if path.is_dir():
|
||||
backup = mf.with_name(mf.name + ".bak")
|
||||
if not backup.exists():
|
||||
shutil.copy2(mf, backup)
|
||||
tmp = mf.with_name(mf.name + ".tmp")
|
||||
tmp.write_text(appended, encoding="utf-8")
|
||||
tmp.replace(mf)
|
||||
return True
|
||||
return _rewrite_zip_manifest(path, appended)
|
||||
|
||||
|
||||
def write_song_metadata(path: Path, fields: dict) -> bool:
|
||||
"""Persist edited title/artist/album/year into the song's file.
|
||||
|
||||
Dispatches by shape: ``.sloppak`` files and sloppak directories
|
||||
Dispatches by shape: zip-form song packages (``.feedpak`` / legacy
|
||||
``.sloppak``, per ``sloppak.SONG_EXTS``) and package directories
|
||||
(manifest.yaml present). Loose-folder and unknown shapes return False
|
||||
(caller keeps the DB-only update). Returns True if the file was modified.
|
||||
"""
|
||||
from sloppak import SONG_EXTS
|
||||
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
if path.is_dir():
|
||||
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
|
||||
return write_sloppak_metadata(path, fields)
|
||||
return False
|
||||
if suffix == ".sloppak":
|
||||
if suffix in SONG_EXTS:
|
||||
return write_sloppak_metadata(path, fields)
|
||||
return False
|
||||
|
||||
+364
-33
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
|
||||
FastAPI / SQLite module-level side effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
DEFAULT_REFERENCE_PITCH = 440.0
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. This is the authoritative source; tuner/routes.py previously
|
||||
# held a copy — it was removed in favour of this one.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
# Canonical open strings, low to high, as MIDI notes. This is the host-level
|
||||
# source of truth for guitar/bass tuning profiles; UI surfaces derive names,
|
||||
# frequencies, and semitone offsets from these absolute pitches.
|
||||
STANDARD_OPEN_MIDIS: dict[str, list[int]] = {
|
||||
"guitar-6": [40, 45, 50, 55, 59, 64],
|
||||
"guitar-7": [35, 40, 45, 50, 55, 59, 64],
|
||||
"guitar-8": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"bass-4": [28, 33, 38, 43],
|
||||
"bass-5": [23, 28, 33, 38, 43],
|
||||
"bass-6": [23, 28, 33, 38, 43, 48],
|
||||
}
|
||||
|
||||
# Curated built-in profiles. This intentionally starts by absorbing the useful
|
||||
# Virtuoso guitar/bass coverage into host-owned data so the host selector,
|
||||
# tuner, practice tools, and plugins can converge on one profile model.
|
||||
TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
|
||||
"guitar-6": {
|
||||
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
|
||||
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
|
||||
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
|
||||
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
|
||||
"Standard": [40, 45, 50, 55, 59, 64],
|
||||
"Eb Standard": [39, 44, 49, 54, 58, 63],
|
||||
"D Standard": [38, 43, 48, 53, 57, 62],
|
||||
"C# Standard": [37, 42, 47, 52, 56, 61],
|
||||
"C Standard": [36, 41, 46, 51, 55, 60],
|
||||
"Drop D": [38, 45, 50, 55, 59, 64],
|
||||
"Drop C": [36, 43, 48, 53, 57, 62],
|
||||
"Drop B": [35, 42, 47, 52, 56, 61],
|
||||
"Drop A": [33, 40, 45, 50, 54, 59],
|
||||
"Drop Ab": [32, 39, 44, 49, 53, 58],
|
||||
"Open G": [38, 43, 50, 55, 59, 62],
|
||||
"Open D": [38, 45, 50, 54, 57, 62],
|
||||
"DADGAD": [38, 45, 50, 55, 57, 62],
|
||||
"Open E": [40, 47, 52, 56, 59, 64],
|
||||
},
|
||||
"guitar-7": {
|
||||
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Standard": [35, 40, 45, 50, 55, 59, 64],
|
||||
"Bb Standard": [34, 39, 44, 49, 54, 58, 63],
|
||||
"A Standard": [33, 38, 43, 48, 53, 57, 62],
|
||||
"G Standard": [31, 36, 41, 46, 51, 55, 60],
|
||||
"Drop A": [33, 40, 45, 50, 55, 59, 64],
|
||||
"Drop G": [31, 38, 43, 48, 53, 57, 62],
|
||||
"Drop F#": [30, 37, 42, 47, 52, 56, 61],
|
||||
},
|
||||
"guitar-8": {
|
||||
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
|
||||
"Standard": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
|
||||
"E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
|
||||
"Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
|
||||
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
|
||||
},
|
||||
"bass-4": {
|
||||
"Standard": [41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [28, 33, 38, 43],
|
||||
"Eb Standard": [27, 32, 37, 42],
|
||||
"D Standard": [26, 31, 36, 41],
|
||||
"C# Standard": [25, 30, 35, 40],
|
||||
"C Standard": [24, 29, 34, 39],
|
||||
"Drop D": [26, 33, 38, 43],
|
||||
"Drop C": [24, 31, 36, 41],
|
||||
"BEAD": [23, 28, 33, 38],
|
||||
},
|
||||
"bass-5": {
|
||||
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [23, 28, 33, 38, 43],
|
||||
"High C": [28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42],
|
||||
"D Standard": [21, 26, 31, 36, 41],
|
||||
"C# Standard": [20, 25, 30, 35, 40],
|
||||
"C Standard": [19, 24, 29, 34, 39],
|
||||
"Drop A": [21, 28, 33, 38, 43],
|
||||
},
|
||||
"bass-6": {
|
||||
"Standard": [23, 28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42, 47],
|
||||
"D Standard": [21, 26, 31, 36, 41, 46],
|
||||
"C# Standard": [20, 25, 30, 35, 40, 45],
|
||||
"C Standard": [19, 24, 29, 34, 39, 44],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float:
|
||||
"""Return the frequency for a MIDI note at the supplied A4 reference."""
|
||||
return reference_pitch * math.pow(2, (midi - 69) / 12)
|
||||
|
||||
|
||||
def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]:
|
||||
"""Return rounded frequencies for low-to-high MIDI open strings."""
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(midis):
|
||||
return None
|
||||
return [int(m - s) for m, s in zip(midis, standard)]
|
||||
|
||||
|
||||
def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for host semitone offsets."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(offsets):
|
||||
return None
|
||||
return [int(s + o) for s, o in zip(standard, offsets)]
|
||||
|
||||
|
||||
def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None:
|
||||
"""Return host semitone offsets for a named preset."""
|
||||
midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name)
|
||||
if not midis:
|
||||
return None
|
||||
return tuning_offsets_from_midis(instrument_key, midis)
|
||||
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. Kept for the existing /api/tunings contract.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
instrument: {
|
||||
name: open_midis_to_freqs(midis)
|
||||
for name, midis in presets.items()
|
||||
}
|
||||
for instrument, presets in TUNING_PRESET_MIDIS.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +148,256 @@ def apply_reference_pitch(
|
||||
}
|
||||
|
||||
|
||||
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
|
||||
PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio")
|
||||
DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead"
|
||||
PROFILE_DEFAULTS: dict[str, dict] = {
|
||||
"guitar-lead": {
|
||||
"id": "guitar-lead",
|
||||
"label": "Lead Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "lead",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"guitar-rhythm": {
|
||||
"id": "guitar-rhythm",
|
||||
"label": "Rhythm Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "rhythm",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"bass": {
|
||||
"id": "bass",
|
||||
"label": "Bass",
|
||||
"instrument": "bass",
|
||||
"role": "bass",
|
||||
"string_count": 4,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def instrument_key(instrument: str, string_count: int) -> str:
|
||||
return f"{instrument}-{string_count}"
|
||||
|
||||
|
||||
def default_instrument_profiles() -> dict[str, dict]:
|
||||
return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()}
|
||||
|
||||
|
||||
def _valid_reference_pitch(value) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
ref = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(ref) or ref < 430.0 or ref > 450.0:
|
||||
return None
|
||||
return ref
|
||||
|
||||
|
||||
def _valid_tuning_for_key(key: str, tuning):
|
||||
if isinstance(tuning, str):
|
||||
if len(tuning) > 64:
|
||||
return None
|
||||
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
||||
return tuning
|
||||
# A name that IS a built-in preset for a different key is a misapplied
|
||||
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
|
||||
# reject it. A name unknown to every built-in table is a provider/custom
|
||||
# tuning (the tuner plugin's, exposed via /api/tunings) that this pure
|
||||
# layer can't resolve — accept it so settings round-trip; the provider
|
||||
# owns its validity.
|
||||
if any(tuning in names for names in TUNING_PRESET_MIDIS.values()):
|
||||
return None
|
||||
return tuning
|
||||
if isinstance(tuning, list):
|
||||
expected = len(STANDARD_OPEN_MIDIS.get(key, []))
|
||||
if len(tuning) != expected:
|
||||
return None
|
||||
if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning):
|
||||
return None
|
||||
return list(tuning)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]:
|
||||
"""Validate one persisted host instrument profile."""
|
||||
base = dict(PROFILE_DEFAULTS.get(profile_id, {}))
|
||||
if not base:
|
||||
return None, f"unknown instrument profile: {profile_id}"
|
||||
if raw is None:
|
||||
return base, None
|
||||
if not isinstance(raw, dict):
|
||||
return None, f"instrument_profiles.{profile_id} must be an object"
|
||||
|
||||
instrument = raw.get("instrument", base["instrument"])
|
||||
if instrument not in ("guitar", "bass"):
|
||||
return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'"
|
||||
|
||||
try:
|
||||
string_count = int(raw.get("string_count", base["string_count"]))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
key = instrument_key(instrument, string_count)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
|
||||
tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]))
|
||||
if tuning is None:
|
||||
return None, f"instrument_profiles.{profile_id}.tuning must match {key}"
|
||||
|
||||
ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"]))
|
||||
if ref is None:
|
||||
return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450"
|
||||
|
||||
label = raw.get("label", base["label"])
|
||||
if not isinstance(label, str) or len(label) > 64:
|
||||
return None, f"instrument_profiles.{profile_id}.label must be a short string"
|
||||
role = raw.get("role", base["role"])
|
||||
if not isinstance(role, str) or len(role) > 32:
|
||||
return None, f"instrument_profiles.{profile_id}.role must be a short string"
|
||||
pathway = raw.get("pathway", base["pathway"])
|
||||
if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS:
|
||||
return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio"
|
||||
|
||||
out = dict(base)
|
||||
out.update({
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"instrument": instrument,
|
||||
"role": role,
|
||||
"string_count": string_count,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return out, None
|
||||
|
||||
|
||||
def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]:
|
||||
"""Validate persisted host profiles, filling omitted built-ins with defaults."""
|
||||
if raw_profiles is None:
|
||||
return default_instrument_profiles(), None
|
||||
if not isinstance(raw_profiles, dict):
|
||||
return None, "instrument_profiles must be an object"
|
||||
profiles = {}
|
||||
for profile_id in PROFILE_IDS:
|
||||
profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id))
|
||||
if error:
|
||||
return None, error
|
||||
profiles[profile_id] = profile
|
||||
return profiles, None
|
||||
|
||||
|
||||
def active_profile_id(raw) -> str:
|
||||
return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
|
||||
|
||||
def profile_from_legacy_settings(cfg: dict) -> dict:
|
||||
"""Build an active profile from the old flat settings keys."""
|
||||
instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar"
|
||||
fallback_sc = 4 if instrument == "bass" else 6
|
||||
try:
|
||||
sc = int(cfg.get("string_count", fallback_sc))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard"
|
||||
ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH
|
||||
pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs"
|
||||
profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
profile = dict(PROFILE_DEFAULTS[profile_id])
|
||||
profile.update({
|
||||
"instrument": instrument,
|
||||
"string_count": sc,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return profile
|
||||
|
||||
|
||||
def settings_with_instrument_profiles(cfg: dict) -> dict:
|
||||
"""Return settings with canonical host profiles and mirrored flat keys."""
|
||||
out = dict(cfg)
|
||||
profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"))
|
||||
if profiles is None:
|
||||
profiles = default_instrument_profiles()
|
||||
if "instrument_profiles" not in out:
|
||||
legacy = profile_from_legacy_settings(out)
|
||||
profiles[legacy["id"]] = legacy
|
||||
# Default the active profile to the one migrated from the legacy flat
|
||||
# fields, but DON'T clobber an explicit request — a fresh-config
|
||||
# `POST {"active_instrument_profile": "bass"}` must switch, not be
|
||||
# overwritten by the guitar-lead inferred from defaults. active_profile_id
|
||||
# below normalizes an invalid value.
|
||||
out.setdefault("active_instrument_profile", legacy["id"])
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
selected = profiles[active]
|
||||
out["instrument_profiles"] = profiles
|
||||
out["active_instrument_profile"] = active
|
||||
out["instrument"] = selected["instrument"]
|
||||
out["string_count"] = selected["string_count"]
|
||||
out["tuning"] = selected["tuning"]
|
||||
out["reference_pitch"] = selected["reference_pitch"]
|
||||
out["pathway"] = selected["pathway"]
|
||||
return out
|
||||
|
||||
|
||||
def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
"""Mirror legacy flat instrument updates into the active host profile."""
|
||||
out = settings_with_instrument_profiles(cfg)
|
||||
if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")):
|
||||
return out
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
if "instrument" in updates:
|
||||
active = "bass" if updates["instrument"] == "bass" else "guitar-lead"
|
||||
out["active_instrument_profile"] = active
|
||||
current = dict(out["instrument_profiles"][active])
|
||||
|
||||
if "instrument" in updates:
|
||||
current["instrument"] = updates["instrument"]
|
||||
if "string_count" not in updates:
|
||||
current["string_count"] = 4 if updates["instrument"] == "bass" else 6
|
||||
if "string_count" in updates:
|
||||
current["string_count"] = updates["string_count"]
|
||||
if "reference_pitch" in updates:
|
||||
current["reference_pitch"] = updates["reference_pitch"]
|
||||
if "pathway" in updates:
|
||||
current["pathway"] = updates["pathway"]
|
||||
if "tuning" in updates:
|
||||
current["tuning"] = updates["tuning"]
|
||||
else:
|
||||
key = instrument_key(current["instrument"], current["string_count"])
|
||||
if _valid_tuning_for_key(key, current.get("tuning")) is None:
|
||||
current["tuning"] = "Standard"
|
||||
|
||||
profile, error = normalize_instrument_profile(active, current)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
out["instrument_profiles"][active] = profile
|
||||
out.update({
|
||||
"instrument": profile["instrument"],
|
||||
"string_count": profile["string_count"],
|
||||
"tuning": profile["tuning"],
|
||||
"reference_pitch": profile["reference_pitch"],
|
||||
"pathway": profile["pathway"],
|
||||
})
|
||||
return out
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# All three pattern checks below are gated on `len(offsets) == 6`. The
|
||||
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
|
||||
|
||||
Generated
+1758
-1
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -8,9 +8,12 @@
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium"
|
||||
"install:playwright": "playwright install chromium",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
+99
-15
@@ -18,6 +18,54 @@ from safepath import safe_join
|
||||
log = logging.getLogger("feedBack.plugins")
|
||||
|
||||
|
||||
def _plugin_media_type(path: Path) -> str:
|
||||
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
|
||||
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
|
||||
accept them; `mimetypes.guess_type` can miss these on a stripped platform
|
||||
registry, so fall back explicitly (mirrors the assets/ route)."""
|
||||
media_type = mimetypes.guess_type(path.name)[0]
|
||||
if media_type is None and path.suffix == ".js":
|
||||
return "application/javascript"
|
||||
if media_type is None and path.suffix == ".css":
|
||||
return "text/css"
|
||||
return media_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _plugin_file_etag(path: Path) -> str | None:
|
||||
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
|
||||
This is what makes the live-edit loop work for module graphs: a conditional
|
||||
GET revalidates and 304s unchanged files on refresh instead of re-downloading
|
||||
the whole `src/` tree. Returns None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
||||
|
||||
|
||||
def _if_none_match(request: Request, etag: str) -> bool:
|
||||
"""True when the client's If-None-Match already holds `etag`."""
|
||||
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
|
||||
# a direct compare is enough (comma-split tolerates a proxy concatenation).
|
||||
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
|
||||
|
||||
|
||||
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
|
||||
"""Serve a plugin source/asset file with the live-edit cache contract:
|
||||
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
|
||||
ETag, and a bodyless 304 when the client's If-None-Match already matches.
|
||||
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
|
||||
itself, so the conditional handling has to live here."""
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
etag = _plugin_file_etag(path)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if _if_none_match(request, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
|
||||
return FileResponse(path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent
|
||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||
@@ -1373,6 +1421,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"version": manifest.get("version"),
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
# Module-migration (R0): `scriptType:"module"` tells the loader to
|
||||
# inject screen.js as <script type="module">; `minHost` is the
|
||||
# min core version a migrated plugin needs (passthrough only in R0 —
|
||||
# enforcement is deferred to R4, master §4b). None when unset.
|
||||
"script_type": manifest.get("scriptType"),
|
||||
"min_host": manifest.get("minHost"),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
@@ -2089,6 +2143,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": p.get("fallback", False),
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
# Module-migration passthrough (R0). Re-read from the manifest
|
||||
# like `version` above so stubbed test entries (built without
|
||||
# _nav_entry) don't need the key.
|
||||
"script_type": (p.get("_manifest") or {}).get("scriptType"),
|
||||
"min_host": (p.get("_manifest") or {}).get("minHost"),
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
@@ -2142,6 +2201,9 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": False,
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
# Pending entries come from _nav_entry, so they carry these.
|
||||
"script_type": e.get("script_type"),
|
||||
"min_host": e.get("min_host"),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
@@ -2307,7 +2369,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return HTMLResponse("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/screen.js")
|
||||
def plugin_screen_js(plugin_id: str):
|
||||
def plugin_screen_js(request: Request, plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
@@ -2315,8 +2377,11 @@ def register_plugin_api(app: FastAPI):
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||
if script_file.exists():
|
||||
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||
if script_file.is_file():
|
||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
||||
# refresh while an unchanged one revalidates cheaply — the
|
||||
# same live-edit contract the src/ module graph relies on.
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
@@ -2377,7 +2442,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return Response("{}", status_code=404, media_type="application/json")
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
||||
def plugin_asset(plugin_id: str, asset_path: str):
|
||||
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
|
||||
"""Serve a static file a plugin bundles under its own ``assets/``
|
||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||
fixed screen.js/settings.html handlers above, this is a generic
|
||||
@@ -2399,16 +2464,35 @@ def register_plugin_api(app: FastAPI):
|
||||
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
||||
break
|
||||
if target.is_file():
|
||||
media_type = mimetypes.guess_type(target.name)[0]
|
||||
# .js must come back as JavaScript so addModule() / <script>
|
||||
# accept it; guess_type can miss this on some platforms.
|
||||
if media_type is None and target.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
# .css must come back as text/css so a <link rel=stylesheet>
|
||||
# (the styles capability) is honoured; guess_type can miss it
|
||||
# on a stripped platform mimetypes registry, same as .js.
|
||||
elif media_type is None and target.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
||||
|
||||
This is the R0 host capability that lets a migrated plugin's
|
||||
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
|
||||
module graph. Containment mirrors the assets/ route exactly —
|
||||
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
|
||||
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
|
||||
makes an edited module reload on refresh while unchanged ones 304.
|
||||
Read-only; the src/ tree is source files, never executed server-side.
|
||||
"""
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
if p["id"] == plugin_id:
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
target = safe_join(p["_dir"] / "src", src_path)
|
||||
if target is None:
|
||||
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
|
||||
break
|
||||
if target.is_file():
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,100 @@
|
||||
# 3D Drum Highway — Mockup
|
||||
|
||||

|
||||
|
||||
An exploratory **pure-visual** sibling of `highway_3d`. Renders an 8-lane
|
||||
drum highway (7 lanes for hand pieces + a full-width kick bar) populated
|
||||
from a hardcoded demo pattern that loops indefinitely. Not yet wired to
|
||||
song data, hit detection, audio, or note_detect — this is here to play
|
||||
with the look-and-feel.
|
||||
|
||||
To see it, load any song in the player, then pick **3D Drum Highway**
|
||||
from the viz picker. The mockup animates regardless of what song is
|
||||
playing.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
[HH] [SNR] [TM1] [TM2] [FT] [CR] [RD] <- 7 lanes, left to right
|
||||
| | | | | | |
|
||||
v v v v v v v
|
||||
----- hit line -----------------------------
|
||||
▓▓▓▓▓▓▓▓▓▓▓▓▓ KICK BAR ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ <- full-width kick lane
|
||||
```
|
||||
|
||||
- **Drums** (snare, toms, floor tom): flat disc geometry (`CylinderGeometry`),
|
||||
palette-tinted, with subtle emissive pulse on approach. Snare gets a
|
||||
thin white "wires" stripe.
|
||||
- **Cymbals** (hi-hat, crash, ride): faceted gem geometry (truncated
|
||||
`CylinderGeometry`), metallic material, slightly translucent.
|
||||
- **Kick**: full-width amber bar across the base, brighter on approach.
|
||||
|
||||
## Variants visible in the demo
|
||||
|
||||
| Variant | How it reads |
|
||||
|---|---|
|
||||
| `accent` | Bigger note + white halo ring |
|
||||
| `ghost` | Hollow ring (~65% size) instead of a solid disc |
|
||||
| `flam` | Main note + a small grace disc offset slightly before |
|
||||
| `bell` | Ride only — adds a bright dot in the center of the gem |
|
||||
|
||||
The **Fill showcase** demo pattern fires every variant at least once in
|
||||
a few bars, plus a tom roll down the kit. Best read of what's possible.
|
||||
|
||||
## Settings (Settings → Plugins → 3D Drum Highway)
|
||||
|
||||
- **Palette** — shared 3 palettes with `highway_3d` (default / neon / pastel).
|
||||
- **Demo pattern** — rock backbeat / jazz swing / fill showcase.
|
||||
- **Camera angle** — 0 (down the lanes) to 1 (top-down). Default 0.35.
|
||||
|
||||
All settings persist in `localStorage` under the `drum_h3d_*` prefix.
|
||||
|
||||
## What this plugin is *not* doing yet
|
||||
|
||||
(Historical note: this started as a pure-visual mockup; it now reads
|
||||
`bundle.drumTab` + `bundle.currentTime` and scores MIDI hits against the
|
||||
chart, so the old "no song-data wiring" caveats are gone.)
|
||||
|
||||
- Sustain trails (drums don't sustain meaningfully)
|
||||
- Sticking labels, double kick, rolls — see the TODOs in `screen.js`
|
||||
for the variant backlog
|
||||
|
||||
## Ported helpers (keep in sync with highway_3d)
|
||||
|
||||
Visual-parity code copied from `plugins/highway_3d/screen.js` — same
|
||||
function names, signatures, and constants on purpose, marked with
|
||||
`PORTED FROM highway_3d` comments at each site. If the guitar highway
|
||||
tunes one of these, mirror the change here (and in `keys_highway_3d`):
|
||||
|
||||
- `_bloomEnsure()` / `_bloomDispose()` — EffectComposer + UnrealBloomPass
|
||||
(0.65/0.5/0.82) on a multisampled HalfFloat target, ACES↔None tone-
|
||||
mapping switch in `draw()`; addons dynamic-imported from
|
||||
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
||||
graceful degrade)
|
||||
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
|
||||
(pool 160 here vs the guitar's 256)
|
||||
- `_makeGaussTex()` — soft-falloff DataTexture for the additive lane-flash
|
||||
quads
|
||||
- `_timingHex()` — early/late/on-time feedback colors (green/cyan/amber)
|
||||
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
|
||||
checks, which it needs for input routing and we don't)
|
||||
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
|
||||
as the guitar's, except `default` which is this plugin's original
|
||||
palette); one pick drives both of the guitar's background/highway axes
|
||||
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
|
||||
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
|
||||
`_bgReadBands()` — background ambience + the stems-first audio-analyser
|
||||
bridge (guitar's diagnostics plumbing dropped; butterchurn/image/video
|
||||
out of scope)
|
||||
- `_drawScoreFx()` — the guitar's drawScoreFx overlay adapted to this
|
||||
plugin's internal scoring (pops / tier rings / milestone bursts /
|
||||
streak-break wash)
|
||||
|
||||
## Why a separate plugin (vs. drum mode inside highway_3d)?
|
||||
|
||||
Cleaner iteration. The drum highway is its own geometry, its own
|
||||
gameplay assumptions (lanes ≠ strings, no frets, no chord shapes,
|
||||
no sustains), and likely its own chart format. Forking the visuals
|
||||
in a sibling plugin lets the mockup move fast without risking
|
||||
regressions in the guitar viz that ships today. If the drum highway
|
||||
eventually matures, we can decide whether to merge or keep separate.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="3D Drum Highway placeholder thumbnail">
|
||||
<rect width="256" height="256" rx="36" fill="#0f172a"/>
|
||||
<rect x="18" y="18" width="220" height="220" rx="28" fill="#1e293b" stroke="#334155" stroke-width="3"/>
|
||||
<text x="128" y="150" font-family="Rubik, Arial, sans-serif" font-size="104" font-weight="800" fill="#38bdf8" text-anchor="middle">3D</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 464 B |
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "drum_highway_3d",
|
||||
"name": "3D Drum Highway",
|
||||
"version": "0.3.1",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "graphics"
|
||||
},
|
||||
"standards": [
|
||||
"capability-pipelines.v1",
|
||||
"plugin-runtime-idempotent.v1"
|
||||
],
|
||||
"capabilities": {
|
||||
"midi-input": {
|
||||
"roles": [
|
||||
"requester"
|
||||
],
|
||||
"requests": [
|
||||
"discover",
|
||||
"list-sources",
|
||||
"select-source",
|
||||
"open-source",
|
||||
"close-source"
|
||||
],
|
||||
"mode": "active",
|
||||
"compatibility": "degrade-noop",
|
||||
"ownership": "requester-only",
|
||||
"safety": "sensitive",
|
||||
"description": "Reads the e-kit/MIDI-pad input through the core midi-input domain (Web-MIDI provider ships built-in with the domain). Absent domain \u2192 no MIDI devices, fail-soft.",
|
||||
"version": 1
|
||||
}
|
||||
},
|
||||
"category": "practice",
|
||||
"description": "3D note highway for drum charts.",
|
||||
"icon": "assets/thumb.svg"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,615 @@
|
||||
<div role="group" aria-labelledby="drumh3d-heading">
|
||||
<h3 id="drumh3d-heading" class="text-sm font-medium text-gray-400 mb-2">3D Drum Highway</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Pick a song with a drum tab (sloppak-spec §5.3), choose <em>3D
|
||||
Drum Highway</em> from the viz picker. The plugin auto-attaches
|
||||
to your MIDI controller and routes chart pieces through the kit
|
||||
you configure below.
|
||||
</p>
|
||||
|
||||
<!-- Palette -->
|
||||
<div class="mt-3">
|
||||
<label for="drumh3d-palette" class="text-xs font-medium text-gray-400 mb-1 block">Palette</label>
|
||||
<select id="drumh3d-palette"
|
||||
onchange="window.drumH3dSetPalette && window.drumH3dSetPalette(this.value); (window._drumH3dRenderPaletteSwatches || function(){})(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="default">Default (Classic)</option>
|
||||
<option value="neon">Neon (saturated, electric)</option>
|
||||
<option value="pastel">Pastel (soft, low contrast)</option>
|
||||
</select>
|
||||
<div class="mt-2 flex items-center gap-1" aria-hidden="true">
|
||||
<span id="drumh3d-swatch-0" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-1" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-2" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-3" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-4" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-5" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-6" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
|
||||
<span id="drumh3d-swatch-7" class="inline-block h-6 w-5 rounded border border-gray-700/60"
|
||||
style="background:#ffa030"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera angle -->
|
||||
<div class="mt-4">
|
||||
<label for="drumh3d-camera" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Camera angle <span id="drumh3d-camera-val" class="text-gray-500 font-mono">0.35</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-camera"
|
||||
min="0" max="1" step="0.05" value="0.35"
|
||||
oninput="window.drumH3dSetCameraAngle && window.drumH3dSetCameraAngle(this.value); document.getElementById('drumh3d-camera-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">0 = down the lanes · 1 = top-down</p>
|
||||
</div>
|
||||
|
||||
<!-- Graphics -->
|
||||
<div class="mt-6 border-t border-gray-800 pt-4">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
|
||||
<label for="drumh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="drumh3d-fx-theme"
|
||||
onchange="window.drumH3dSetTheme && window.drumH3dSetTheme(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="default">Default (original)</option>
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="charcoal">Charcoal</option>
|
||||
<option value="deeppurple">Deep Purple</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="warmslate">Warm Slate</option>
|
||||
<option value="deepfocus">Deep Focus</option>
|
||||
<option value="deepsea">Deep Sea</option>
|
||||
<option value="cathode">Cathode</option>
|
||||
<option value="cathodegreen">Cathode Green</option>
|
||||
<option value="hearth">Hearth</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background, floor and lane colours — the same theme names as the
|
||||
guitar highway, so your look carries across instruments. Piece
|
||||
colours stay with the Palette above.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="drumh3d-fx-cinematic" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('cinematic', this.checked)">
|
||||
Cinematic lighting
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Dimmer ambience, stronger key light — more depth on the cymbals
|
||||
and drumheads.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Glow strength <span id="drumh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-glow"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('glow', this.value); document.getElementById('drumh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How hard the notes and hit line self-illuminate (0.5 = stock).
|
||||
Pairs with bloom.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Lane vibrancy <span id="drumh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-vibrancy"
|
||||
min="0" max="1" step="0.05" value="0.85"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('vibrancy', this.value); document.getElementById('drumh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Lane stripe and accent-ring strength — lower for a calmer deck.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
|
||||
<select id="drumh3d-fx-bgstyle"
|
||||
onchange="window.drumH3dSetBgStyle && window.drumH3dSetBgStyle(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="off">Off</option>
|
||||
<option value="particles" selected>Particles</option>
|
||||
<option value="lights">Stage lights</option>
|
||||
<option value="geometric">Geometric</option>
|
||||
</select>
|
||||
<label for="drumh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
|
||||
Ambience intensity <span id="drumh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-bgintensity"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('bgIntensity', this.value); document.getElementById('drumh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<label for="drumh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
|
||||
<input type="checkbox" id="drumh3d-fx-bgreactive" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bgReactive', this.checked)">
|
||||
Audio-reactive ambience
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
The backdrop pulses with the mix (stems analyser when a sloppak
|
||||
is loaded). Off = it animates on time only.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-scorefx" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('scoreFx', this.checked)">
|
||||
Score effects
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
+1 pops on hits, a ring pulse every 10-combo, milestone bursts at
|
||||
25/50/100, and a brief red flicker when a streak breaks.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-bloom" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
|
||||
Glow (bloom)
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Soft light-bleed around the hit line and bright notes. Applies
|
||||
live; turn off to reclaim GPU headroom on weak machines.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-sparks" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('sparks', this.checked)">
|
||||
Hit sparks
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
A small particle burst on every scored pad hit.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-timing" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('timingFx', this.checked)">
|
||||
Timing colours
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint hit feedback by timing — on-time green, early cyan, late
|
||||
amber. Off = always green.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="drumh3d-fx-streak" checked
|
||||
onchange="window.drumH3dSetFx && window.drumH3dSetFx('streakFx', this.checked)">
|
||||
Streak feedback
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Spark bursts grow with your combo.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Hit feedback intensity <span id="drumh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-fx-hitfx"
|
||||
min="0" max="1" step="0.05" value="0.7"
|
||||
oninput="window.drumH3dSetFx && window.drumH3dSetFx('hitFx', this.value); document.getElementById('drumh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Drives the lane flashes, approach glow and the kick camera pulse.
|
||||
0 turns them off.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- MIDI input -->
|
||||
<div class="mt-6 border-t border-gray-800 pt-4">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">MIDI input</h4>
|
||||
<label for="drumh3d-midi-input" class="text-xs font-medium text-gray-400 mb-1 block">Device</label>
|
||||
<select id="drumh3d-midi-input"
|
||||
onchange="window.drumH3dSetMidiInput && window.drumH3dSetMidiInput(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="">— none —</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Auto-picks the first non-passthrough input on load. Pick "none" to pause hit and miss tracking.
|
||||
</p>
|
||||
|
||||
<label for="drumh3d-synth-vol" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Kit volume <span id="drumh3d-synth-vol-val" class="text-gray-500 font-mono">0.70</span>
|
||||
</label>
|
||||
<input type="range" id="drumh3d-synth-vol"
|
||||
min="0" max="1" step="0.01" value="0.7"
|
||||
oninput="window.drumH3dSetSynthVolume && window.drumH3dSetSynthVolume(this.value); document.getElementById('drumh3d-synth-vol-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
</div>
|
||||
|
||||
<!-- Kit configuration -->
|
||||
<div class="mt-6 border-t border-gray-800 pt-4">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">My kit</h4>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Add the pieces you actually have, in the order you want them
|
||||
on the highway (left → right). The default fallback routing
|
||||
covers most common kit setups; use the fallbacks panel below
|
||||
to reroute any chart piece to a different lane on your kit,
|
||||
or set it to "—" to silently drop it.
|
||||
</p>
|
||||
|
||||
<label class="text-xs font-medium text-gray-400 mb-1 block">Kit name</label>
|
||||
<input type="text" id="drumh3d-kit-name" maxlength="80"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none mb-3"
|
||||
oninput="window._drumH3dKit && window._drumH3dKit.onNameInput(this.value)">
|
||||
|
||||
<label class="text-xs font-medium text-gray-400 mb-1 block">Lanes (left → right on the highway; kick is always a full-width bar)</label>
|
||||
<div id="drumh3d-kit-lanes" class="space-y-1 mb-2"></div>
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<label class="text-xs font-medium text-gray-400 mb-1 block">Add a piece</label>
|
||||
<select id="drumh3d-kit-add"
|
||||
onchange="window._drumH3dKit && window._drumH3dKit.onAddPiece(this.value); this.value=''"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="">— pick a piece to add —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<details class="mt-2 mb-3">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer">Chart fallbacks for pieces I don't have</summary>
|
||||
<p class="text-xs text-gray-500 mt-2 mb-2">
|
||||
When a song uses a piece that isn't on your kit, route it
|
||||
to one of your lanes. Set "—" to silently drop that piece.
|
||||
</p>
|
||||
<div id="drumh3d-kit-fallbacks" class="space-y-1"></div>
|
||||
</details>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 mt-3">
|
||||
<button type="button"
|
||||
onclick="window._drumH3dKit && window._drumH3dKit.copyToClipboard()"
|
||||
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">📋 Copy kit</button>
|
||||
<button type="button"
|
||||
onclick="document.getElementById('drumh3d-kit-import-row').classList.toggle('hidden')"
|
||||
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">📥 Import kit…</button>
|
||||
<button type="button"
|
||||
onclick="window._drumH3dKit && window._drumH3dKit.resetDefault()"
|
||||
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">Reset</button>
|
||||
<span id="drumh3d-kit-status" class="text-xs text-gray-500 ml-auto"></span>
|
||||
</div>
|
||||
|
||||
<div id="drumh3d-kit-import-row" class="hidden mt-3">
|
||||
<label class="text-xs font-medium text-gray-400 mb-1 block">Paste a shared kit</label>
|
||||
<textarea id="drumh3d-kit-import-text" rows="3"
|
||||
placeholder="Paste a base64 kit string here…"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-[10px] font-mono text-gray-300 outline-none"></textarea>
|
||||
<button type="button"
|
||||
onclick="window._drumH3dKit && window._drumH3dKit.importFromTextarea()"
|
||||
class="mt-2 px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 rounded-lg text-xs">Apply imported kit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Mirror of PALETTES in screen.js — kept in sync for swatch previews.
|
||||
const PALETTES = {
|
||||
default: [0xff2828, 0xffd400, 0x2080ff, 0xff8020, 0x30d040, 0xa040ff, 0xff6bd5, 0x6bffe6],
|
||||
neon: [0xff0030, 0xffe800, 0x0080ff, 0xff8030, 0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0],
|
||||
pastel: [0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898, 0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8],
|
||||
};
|
||||
|
||||
function toHex(n) { return '#' + n.toString(16).padStart(6, '0'); }
|
||||
|
||||
// Palette swatch preview: read the FIRST 8 pieces of the active
|
||||
// kit's lanes (or pad to 8) for colour reference.
|
||||
window._drumH3dRenderPaletteSwatches = function (id) {
|
||||
const pal = PALETTES[id] || PALETTES.default;
|
||||
const kit = (window.drumH3dGetKit && window.drumH3dGetKit()) || null;
|
||||
const lanes = (kit && kit.lanes) || [];
|
||||
// The kick always shows amber — same as the renderer. Other
|
||||
// lanes follow the piece's palette index from screen.js,
|
||||
// retrieved via drumH3dGetPiecePaletteIdx() so settings.html
|
||||
// stays in sync when pieces are added without any copy-paste.
|
||||
const PALETTE_IDX = (window.drumH3dGetPiecePaletteIdx && window.drumH3dGetPiecePaletteIdx()) || {
|
||||
kick: -1, // sentinel — render amber
|
||||
snare: 0, snare_xstick: 0,
|
||||
hh_closed: 7, hh_open: 7, hh_pedal: 7,
|
||||
tom_hi: 4, tom_mid: 2, tom_low: 5, tom_floor: 5,
|
||||
crash_l: 1, crash_r: 1, splash: 1, china: 1,
|
||||
ride: 3, ride_bell: 3,
|
||||
};
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const el = document.getElementById('drumh3d-swatch-' + i);
|
||||
if (!el) continue;
|
||||
const ln = lanes[i];
|
||||
if (!ln) { el.style.background = '#000'; el.style.opacity = '0.3'; continue; }
|
||||
el.style.opacity = '1';
|
||||
const idx = PALETTE_IDX[ln.piece];
|
||||
if (idx === -1) { el.style.background = '#ffa030'; continue; }
|
||||
el.style.background = toHex(pal[idx ?? 0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Read fresh inside renderKit() — they're set by screen.js's IIFE,
|
||||
// which may not have finished evaluating when this inline script
|
||||
// first runs if the user opens Settings before the player ever loaded.
|
||||
|
||||
// Friendly piece labels — self-contained so renderKit's callbacks
|
||||
// (which run after our local PIECE_LABELS scope has cleared)
|
||||
// still work.
|
||||
const FRIENDLY = {
|
||||
kick: 'Kick',
|
||||
snare: 'Snare', snare_xstick: 'Snare (cross-stick)',
|
||||
hh_closed: 'Hi-hat (closed)', hh_open: 'Hi-hat (open)', hh_pedal: 'Hi-hat (pedal)',
|
||||
tom_hi: 'Tom — high', tom_mid: 'Tom — mid', tom_low: 'Tom — low', tom_floor: 'Floor tom',
|
||||
crash_l: 'Crash (left)', crash_r: 'Crash (right)', splash: 'Splash', china: 'China',
|
||||
ride: 'Ride', ride_bell: 'Ride bell',
|
||||
};
|
||||
function friendly(p) { return FRIENDLY[p] || p; }
|
||||
|
||||
// Render the current kit's lane list with reorder + remove buttons.
|
||||
function renderKit() {
|
||||
const kit = window.drumH3dGetKit ? window.drumH3dGetKit() : null;
|
||||
if (!kit) {
|
||||
// screen.js hasn't published its API yet — defer one tick.
|
||||
// Happens when the settings panel opens before the player
|
||||
// has loaded a song.
|
||||
setTimeout(renderKit, 100);
|
||||
return;
|
||||
}
|
||||
const PIECE_CATEGORY = window.drumH3dGetPieceCategory ? window.drumH3dGetPieceCategory() : {};
|
||||
const ALL_PIECES = window.drumH3dGetAllPieces ? window.drumH3dGetAllPieces() : [];
|
||||
document.getElementById('drumh3d-kit-name').value = kit.name || '';
|
||||
|
||||
// Lanes panel — order matters: up/down buttons reorder, x
|
||||
// removes (also clears any fallback that pointed at the
|
||||
// removed piece on the way out).
|
||||
const lanesEl = document.getElementById('drumh3d-kit-lanes');
|
||||
lanesEl.innerHTML = '';
|
||||
const taken = new Set(kit.lanes.map(l => l.piece));
|
||||
kit.lanes.forEach((ln, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center gap-2 px-2 py-1 bg-dark-700/60 rounded';
|
||||
// Screen readers should hear the user-facing label
|
||||
// ("Hi-hat (closed)") not the internal piece id ("hh_closed").
|
||||
const pieceName = friendly(ln.piece);
|
||||
row.innerHTML =
|
||||
`<span class="font-mono text-[10px] text-gray-500 w-6 text-center">${i}</span>` +
|
||||
`<span class="flex-1 text-xs text-gray-300">${pieceName}</span>` +
|
||||
`<button type="button" data-act="up" data-i="${i}" class="px-1.5 py-0.5 text-xs text-gray-400 hover:text-white" ${i === 0 ? 'disabled' : ''} title="Move up" aria-label="Move ${pieceName} up">▲</button>` +
|
||||
`<button type="button" data-act="down" data-i="${i}" class="px-1.5 py-0.5 text-xs text-gray-400 hover:text-white" ${i === kit.lanes.length - 1 ? 'disabled' : ''} title="Move down" aria-label="Move ${pieceName} down">▼</button>` +
|
||||
`<button type="button" data-act="rm" data-i="${i}" class="px-1.5 py-0.5 text-xs text-red-400 hover:text-red-300" title="Remove from kit" aria-label="Remove ${pieceName} from kit">✗</button>`;
|
||||
lanesEl.appendChild(row);
|
||||
});
|
||||
lanesEl.querySelectorAll('button').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const i = parseInt(btn.dataset.i, 10);
|
||||
const act = btn.dataset.act;
|
||||
const next = JSON.parse(JSON.stringify(kit));
|
||||
if (act === 'up' && i > 0) {
|
||||
[next.lanes[i - 1], next.lanes[i]] = [next.lanes[i], next.lanes[i - 1]];
|
||||
} else if (act === 'down' && i < next.lanes.length - 1) {
|
||||
[next.lanes[i + 1], next.lanes[i]] = [next.lanes[i], next.lanes[i + 1]];
|
||||
} else if (act === 'rm') {
|
||||
const removed = next.lanes[i].piece;
|
||||
next.lanes.splice(i, 1);
|
||||
// Drop any fallback that targeted the removed piece
|
||||
// — it's no longer a valid target.
|
||||
for (const k of Object.keys(next.fallbacks || {})) {
|
||||
if (next.fallbacks[k] === removed) delete next.fallbacks[k];
|
||||
}
|
||||
} else { return; }
|
||||
// Guard: drumH3dSetKit returns false when validation
|
||||
// rejects the kit (e.g. empty lanes[] after last removal).
|
||||
if (!window.drumH3dSetKit(next)) {
|
||||
setStatus('Kit must have at least one lane', 'err');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add-a-piece dropdown — pieces NOT already in lanes, grouped
|
||||
// by category (kick / drums / cymbals).
|
||||
const addSel = document.getElementById('drumh3d-kit-add');
|
||||
addSel.innerHTML = '<option value="">— pick a piece to add —</option>';
|
||||
const groups = { kick: [], drum: [], cymbal: [] };
|
||||
for (const p of ALL_PIECES) {
|
||||
if (taken.has(p)) continue;
|
||||
const cat = PIECE_CATEGORY[p] || 'drum';
|
||||
if (groups[cat]) groups[cat].push(p);
|
||||
}
|
||||
for (const [cat, label] of [['kick', 'Kick'], ['drum', 'Drums'], ['cymbal', 'Cymbals']]) {
|
||||
if (!groups[cat].length) continue;
|
||||
const og = document.createElement('optgroup');
|
||||
og.label = label;
|
||||
for (const p of groups[cat]) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p;
|
||||
opt.textContent = friendly(p);
|
||||
og.appendChild(opt);
|
||||
}
|
||||
addSel.appendChild(og);
|
||||
}
|
||||
|
||||
// Fallback list — every piece NOT in lanes gets a dropdown
|
||||
// mapping it to one of the user's pieces (or "—" to drop).
|
||||
const fbEl = document.getElementById('drumh3d-kit-fallbacks');
|
||||
fbEl.innerHTML = '';
|
||||
for (const p of ALL_PIECES) {
|
||||
if (taken.has(p)) continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center gap-2 px-2 py-1';
|
||||
const labelTxt = friendly(p);
|
||||
const current = (kit.fallbacks && kit.fallbacks[p]) || '';
|
||||
let optsHtml = '<option value="">— drop —</option>';
|
||||
for (const ln of kit.lanes) {
|
||||
const sel = ln.piece === current ? ' selected' : '';
|
||||
optsHtml += `<option value="${ln.piece}"${sel}>${friendly(ln.piece)}</option>`;
|
||||
}
|
||||
row.innerHTML =
|
||||
`<span class="flex-1 text-xs text-gray-400">${labelTxt}</span>` +
|
||||
`<span class="text-xs text-gray-600">→</span>` +
|
||||
`<select data-from="${p}" class="bg-dark-700 border border-gray-800 rounded px-2 py-1 text-xs text-gray-300 outline-none">${optsHtml}</select>`;
|
||||
fbEl.appendChild(row);
|
||||
}
|
||||
fbEl.querySelectorAll('select').forEach(s => {
|
||||
s.onchange = () => {
|
||||
const next = JSON.parse(JSON.stringify(kit));
|
||||
next.fallbacks = next.fallbacks || {};
|
||||
if (s.value) next.fallbacks[s.dataset.from] = s.value;
|
||||
else delete next.fallbacks[s.dataset.from];
|
||||
window.drumH3dSetKit(next); // event-driven renderKit() via drum_h3d:kit
|
||||
};
|
||||
});
|
||||
|
||||
// Refresh palette swatches so they reflect the new lane order.
|
||||
if (window._drumH3dRenderPaletteSwatches) {
|
||||
const pSel = document.getElementById('drumh3d-palette');
|
||||
window._drumH3dRenderPaletteSwatches(pSel ? pSel.value : 'default');
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(msg, kind) {
|
||||
const el = document.getElementById('drumh3d-kit-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = 'text-xs ml-auto ' + (kind === 'err' ? 'text-red-400' : kind === 'ok' ? 'text-emerald-400' : 'text-gray-500');
|
||||
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 3000);
|
||||
}
|
||||
|
||||
// Expose the kit interaction surface to the inline onclick / onchange
|
||||
// attributes used by the markup above.
|
||||
window._drumH3dKit = {
|
||||
onNameInput(name) {
|
||||
// Use the name-only setter to avoid triggering 'drum_h3d:kit'
|
||||
// on every keystroke — that event causes a full WebGL scene
|
||||
// teardown/reinit which produces visible stutter while typing.
|
||||
window.drumH3dSetKitName && window.drumH3dSetKitName(name);
|
||||
},
|
||||
onAddPiece(piece) {
|
||||
if (!piece) return;
|
||||
const kit = window.drumH3dGetKit();
|
||||
kit.lanes.push({ piece });
|
||||
window.drumH3dSetKit(kit); // event-driven renderKit() via drum_h3d:kit
|
||||
},
|
||||
copyToClipboard() {
|
||||
const b64 = window.drumH3dExportKit && window.drumH3dExportKit();
|
||||
if (!b64) return setStatus('export failed', 'err');
|
||||
const _showImportFallback = (str) => {
|
||||
// Clipboard unavailable — reveal the import row so the user can
|
||||
// copy the string from the textarea manually.
|
||||
document.getElementById('drumh3d-kit-import-row').classList.remove('hidden');
|
||||
const ta = document.getElementById('drumh3d-kit-import-text');
|
||||
ta.value = str;
|
||||
ta.focus();
|
||||
ta.select();
|
||||
setStatus('clipboard blocked — copy from the textarea below', 'err');
|
||||
};
|
||||
if (!navigator.clipboard) {
|
||||
_showImportFallback(b64);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(b64).then(
|
||||
() => setStatus('✓ kit copied', 'ok'),
|
||||
() => _showImportFallback(b64),
|
||||
);
|
||||
},
|
||||
importFromTextarea() {
|
||||
const txt = document.getElementById('drumh3d-kit-import-text').value;
|
||||
if (!txt.trim()) return setStatus('paste a kit string first', 'err');
|
||||
const ok = window.drumH3dImportKit && window.drumH3dImportKit(txt);
|
||||
// drumH3dImportKit calls drumH3dSetKit which dispatches drum_h3d:kit;
|
||||
// the event listener re-renders the panel, so no explicit renderKit().
|
||||
if (ok) { setStatus('✓ kit imported', 'ok'); }
|
||||
else setStatus('invalid kit string', 'err');
|
||||
},
|
||||
resetDefault() {
|
||||
window.drumH3dResetKit && window.drumH3dResetKit();
|
||||
// drumH3dResetKit calls drumH3dSetKit → dispatches drum_h3d:kit
|
||||
// → event listener re-renders. No explicit renderKit() needed.
|
||||
setStatus('✓ reset to default', 'ok');
|
||||
},
|
||||
};
|
||||
|
||||
// Re-render the kit list whenever the kit changes (covers external
|
||||
// changes from setKit() / importKit() not initiated by this panel).
|
||||
window.addEventListener('drum_h3d:kit', () => renderKit());
|
||||
|
||||
// ─── MIDI device picker ───────────────────────────────
|
||||
function renderMidiPicker() {
|
||||
const sel = document.getElementById('drumh3d-midi-input');
|
||||
if (!sel) return;
|
||||
const inputs = window.drumH3dListMidiInputs ? window.drumH3dListMidiInputs() : [];
|
||||
const current = window.drumH3dGetMidiInputId ? window.drumH3dGetMidiInputId() : '';
|
||||
sel.innerHTML = '<option value="">— none —</option>';
|
||||
for (const inp of inputs) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = inp.id;
|
||||
opt.textContent = inp.name;
|
||||
if (inp.id === current) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Sync volume slider with the actual persisted value (default
|
||||
// 0.7 if storage was empty or never written).
|
||||
const vol = window.drumH3dGetSynthVolume ? window.drumH3dGetSynthVolume() : 0.7;
|
||||
const volSlider = document.getElementById('drumh3d-synth-vol');
|
||||
const volLabel = document.getElementById('drumh3d-synth-vol-val');
|
||||
if (volSlider) volSlider.value = String(vol);
|
||||
if (volLabel) volLabel.textContent = parseFloat(vol).toFixed(2);
|
||||
}
|
||||
window.addEventListener('drum_h3d:midi_devices', renderMidiPicker);
|
||||
// Kick off MIDI initialisation if the player viz never ran (user
|
||||
// opened Settings → 3D Drum Highway directly). _midiInit is
|
||||
// idempotent so this is safe even when the viz is also active.
|
||||
// Re-render the picker when the access promise settles.
|
||||
(function pollMidi() {
|
||||
if (window.drumH3dEnsureMidiInit) {
|
||||
Promise.resolve(window.drumH3dEnsureMidiInit()).then(renderMidiPicker);
|
||||
renderMidiPicker(); // first paint with whatever's cached
|
||||
} else {
|
||||
setTimeout(pollMidi, 100);
|
||||
}
|
||||
})();
|
||||
|
||||
// Hydrate controls from stored config on first paint.
|
||||
// Narrow the try/catch to just localStorage reads — renderKit() and
|
||||
// swatches must always run even when storage is blocked/disabled.
|
||||
const pSel = document.getElementById('drumh3d-palette');
|
||||
const cam = document.getElementById('drumh3d-camera');
|
||||
const camVal = document.getElementById('drumh3d-camera-val');
|
||||
try {
|
||||
const storedPal = localStorage.getItem('drum_h3d_palette');
|
||||
if (storedPal && PALETTES[storedPal]) pSel.value = storedPal;
|
||||
|
||||
const storedCam = parseFloat(localStorage.getItem('drum_h3d_camera_angle'));
|
||||
if (Number.isFinite(storedCam)) {
|
||||
const c = Math.min(1, Math.max(0, storedCam));
|
||||
cam.value = String(c);
|
||||
camVal.textContent = c.toFixed(2);
|
||||
}
|
||||
|
||||
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
|
||||
// Only explicit values override; absent/corrupt keys keep the
|
||||
// default (ON), matching screen.js readFxSettings.
|
||||
const hydrateFxBool = (key, elId) => {
|
||||
const raw = localStorage.getItem('drum_h3d_bg_' + key);
|
||||
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
|
||||
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
|
||||
};
|
||||
hydrateFxBool('bloom', 'drumh3d-fx-bloom');
|
||||
hydrateFxBool('sparks', 'drumh3d-fx-sparks');
|
||||
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
|
||||
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
|
||||
hydrateFxBool('cinematic', 'drumh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'drumh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'drumh3d-fx-scorefx');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('drum_h3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const v = Math.min(1, Math.max(0, n));
|
||||
document.getElementById(elId).value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRange('hitFx', 'drumh3d-fx-hitfx', 'drumh3d-fx-hitfx-val');
|
||||
hydrateFxRange('glow', 'drumh3d-fx-glow', 'drumh3d-fx-glow-val');
|
||||
hydrateFxRange('vibrancy', 'drumh3d-fx-vibrancy', 'drumh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('bgIntensity', 'drumh3d-fx-bgintensity', 'drumh3d-fx-bgintensity-val');
|
||||
const storedStyle = localStorage.getItem('drum_h3d_bg_style');
|
||||
const styleSel = document.getElementById('drumh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
styleSel.value = storedStyle;
|
||||
}
|
||||
const storedTheme = localStorage.getItem('drum_h3d_bg_theme');
|
||||
const themeSel = document.getElementById('drumh3d-fx-theme');
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
renderKit();
|
||||
window._drumH3dRenderPaletteSwatches(pSel.value);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,164 @@
|
||||
// Pure data-layer tests: load screen.js in a bare vm window and exercise the
|
||||
// __test exports (no DOM, no WebGL, no network). Doubles as a lint that no
|
||||
// module-scope code touches document/localStorage outside a try/catch —
|
||||
// the vm window deliberately provides neither.
|
||||
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');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return window.slopsmithViz_drum_highway_3d;
|
||||
}
|
||||
|
||||
test('module loads in a bare vm (no DOM / localStorage at module scope)', () => {
|
||||
const factory = load();
|
||||
assert.equal(typeof factory, 'function');
|
||||
assert.equal(factory.contextType, 'webgl2');
|
||||
});
|
||||
|
||||
test('_variantForHit: ghost > flam > bell > accent > normal precedence', () => {
|
||||
const { _variantForHit } = load().__test;
|
||||
assert.equal(_variantForHit({ g: true, f: true, v: 120 }), 'ghost');
|
||||
assert.equal(_variantForHit({ f: true, v: 120 }), 'flam');
|
||||
assert.equal(_variantForHit({ p: 'ride_bell' }), 'bell');
|
||||
assert.equal(_variantForHit({ v: 100 }), 'accent');
|
||||
assert.equal(_variantForHit({ v: 127 }), 'accent');
|
||||
assert.equal(_variantForHit({ v: 99 }), 'normal');
|
||||
// Missing velocity defaults to 100 → accent.
|
||||
assert.equal(_variantForHit({}), 'accent');
|
||||
});
|
||||
|
||||
test('matchesArrangement: claims drum arrangements', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drums' }), true);
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drum Kit' }), true);
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Percussion' }), true);
|
||||
});
|
||||
|
||||
test('matchesArrangement: never claims without a drum tab', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
assert.equal(matches(null), false);
|
||||
assert.equal(matches({}), false);
|
||||
assert.equal(matches({ arrangement: 'Drums' }), false);
|
||||
});
|
||||
|
||||
test('matchesArrangement: steal-guard — guitar arrangements stay with highway_3d', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
// Full-band pack (drum_tab present) playing a guitar-family part:
|
||||
// first-match-wins Auto order must not hand these to the drum highway.
|
||||
for (const arr of ['Lead', 'Rhythm', 'Bass', 'Combo', 'Guitar 22', 'Alt. Lead']) {
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: arr }), false, arr);
|
||||
}
|
||||
// Keys notation present → the keys/staff viz take it.
|
||||
assert.equal(matches({ has_drum_tab: true, has_notation: true, arrangement: 'Piano' }), false);
|
||||
});
|
||||
|
||||
test('matchesArrangement: claims packs nothing more specific can render', () => {
|
||||
const matches = load().matchesArrangement;
|
||||
// Drum tab + nondescript arrangement, no notation → drummable, claim it.
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: '' }), true);
|
||||
assert.equal(matches({ has_drum_tab: true }), true);
|
||||
// Word-boundary check: "BasslineKeys"-style names don't contain a
|
||||
// guitar-family word as a whole word.
|
||||
assert.equal(matches({ has_drum_tab: true, arrangement: 'Bassline' }), true);
|
||||
});
|
||||
|
||||
test('readFxSettings: defaults survive a localStorage-less environment', () => {
|
||||
const { readFxSettings, FX_DEFAULTS } = load().__test;
|
||||
// The vm window has no localStorage — the try/catch must eat the
|
||||
// ReferenceError and hand back pure defaults (everything ON).
|
||||
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
|
||||
assert.equal(FX_DEFAULTS.bloom, true);
|
||||
});
|
||||
|
||||
test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
|
||||
const { MIDI_TO_PIECE, HIT_TOLERANCE_S } = load().__test;
|
||||
assert.equal(MIDI_TO_PIECE[46], 'hh_open');
|
||||
assert.equal(MIDI_TO_PIECE[42], 'hh_closed');
|
||||
assert.equal(MIDI_TO_PIECE[35], 'kick');
|
||||
assert.equal(MIDI_TO_PIECE[36], 'kick');
|
||||
// ±50 ms window matches the 2D drums plugin.
|
||||
assert.equal(HIT_TOLERANCE_S, 0.05);
|
||||
});
|
||||
|
||||
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
|
||||
const { _classifyTiming, HIT_TOLERANCE_S } = load().__test;
|
||||
const tol = HIT_TOLERANCE_S; // 0.05
|
||||
assert.equal(_classifyTiming(0, tol), 'OK');
|
||||
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK'); // boundary inclusive
|
||||
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
|
||||
// delta = note.t - now: positive → struck before the note → EARLY.
|
||||
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
|
||||
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
|
||||
assert.equal(_classifyTiming(tol, tol), 'EARLY');
|
||||
assert.equal(_classifyTiming(-tol, tol), 'LATE');
|
||||
// Degenerate inputs read as on-time rather than throwing.
|
||||
assert.equal(_classifyTiming(NaN, tol), 'OK');
|
||||
});
|
||||
|
||||
test('FX defaults: hit-FX controls ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().__test;
|
||||
assert.equal(FX_DEFAULTS.sparks, true);
|
||||
assert.equal(FX_DEFAULTS.timingFx, true);
|
||||
assert.equal(FX_DEFAULTS.streakFx, true);
|
||||
assert.equal(FX_DEFAULTS.hitFx, 0.7);
|
||||
});
|
||||
|
||||
test('themes: table ids match the guitar highway, default is the stock palette', () => {
|
||||
const { BG_THEMES, _bgThemeColors } = load().__test;
|
||||
// Same id set as highway_3d's BG_THEMES (cross-instrument consistency).
|
||||
assert.deepEqual(Object.keys(BG_THEMES), [
|
||||
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
|
||||
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
|
||||
]);
|
||||
// 'default' preserves THIS plugin's original look byte-for-byte.
|
||||
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
|
||||
assert.equal(BG_THEMES.default.board, 0x0a0e1a);
|
||||
assert.equal(BG_THEMES.default.lane, undefined); // stock stripes fallback
|
||||
// Unknown ids fall back to default.
|
||||
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
|
||||
// Every non-default theme carries a lane pair (the axis differentiator).
|
||||
for (const [id, t] of Object.entries(BG_THEMES)) {
|
||||
if (id === 'default') continue;
|
||||
assert.ok(t.lane != null && t.laneDim != null, id + ' lane pair');
|
||||
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
|
||||
}
|
||||
});
|
||||
|
||||
test('readThemeSetting: defaults without localStorage; validates ids', () => {
|
||||
const { readThemeSetting } = load().__test;
|
||||
assert.equal(readThemeSetting(), 'default');
|
||||
});
|
||||
|
||||
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
|
||||
const { FX_DEFAULTS } = load().__test;
|
||||
assert.equal(FX_DEFAULTS.cinematic, true);
|
||||
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
|
||||
assert.equal(FX_DEFAULTS.vibrancy, 0.85); // ≈ the stock 0.32 stripe base
|
||||
});
|
||||
|
||||
test('bg styles: validated id set, particles default, no out-of-scope styles', () => {
|
||||
const { BG_STYLE_IDS, readBgStyleSetting } = load().__test;
|
||||
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
|
||||
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
|
||||
assert.equal(readBgStyleSetting(), 'particles'); // no localStorage in the vm
|
||||
});
|
||||
|
||||
test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().__test;
|
||||
assert.equal(FX_DEFAULTS.scoreFx, true);
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
@@ -157,6 +157,8 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
|
||||
|
||||
`tuning` and `capo` aren't consumed by this plugin.
|
||||
|
||||
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
|
||||
|
||||
### Score FX (notedetect game-scoring layer)
|
||||
|
||||
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.30.0",
|
||||
"version": "3.31.3",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+985
-96
File diff suppressed because it is too large
Load Diff
@@ -51,15 +51,16 @@
|
||||
sources = sources.filter((s) => s
|
||||
&& !/midi/i.test(String(s.providerId || ''))
|
||||
&& !/^midi-input/i.test(String(s.label || '')));
|
||||
// De-dupe by display label — the desktop engine enumerates the same
|
||||
// device under several driver types, so the same name can repeat.
|
||||
const seen = new Set();
|
||||
sources = sources.filter((s) => {
|
||||
const key = String(s.label || '').toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
// No label de-dupe here. The audio-input capability already
|
||||
// collapses exact duplicates by logicalSourceKey
|
||||
// (_visibleInputSources), so nothing it returns shares a key. A
|
||||
// device that enumerates under several driver types (ASIO / Windows
|
||||
// Audio / DirectSound) has a DISTINCT key per type and is now
|
||||
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
|
||||
// a real, separately-selectable input the user must be able to see.
|
||||
// The old bare-label collapse also kept whichever variant sorted
|
||||
// first, which could silently drop the one that was actually
|
||||
// `selected` below.
|
||||
const selected = sources.find((s) => s && s.selected) || null;
|
||||
return { sources, selected };
|
||||
} catch (_) { return { sources: [], selected: null }; }
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Keys Highway 3D
|
||||
|
||||
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
|
||||
|
||||
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
|
||||
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
|
||||
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
|
||||
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
|
||||
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 0–1).
|
||||
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
|
||||
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
|
||||
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
|
||||
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
|
||||
them, and each sharp leaned toward the edge natural beside it so the naturals come
|
||||
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
|
||||
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
|
||||
naturals always rendered full, full black keys drawn on top and only occluding a
|
||||
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
|
||||
**Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the
|
||||
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
|
||||
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
|
||||
rather than every lane — the notes keep their colors; toward 1 it fills in full,
|
||||
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
|
||||
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
|
||||
gap a touch at each B→C octave boundary. **Octave line contrast**
|
||||
(`keys3d_bg_octaveContrast`, 0–1, default 0.5) scales how hard the B→C octave line
|
||||
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
|
||||
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
|
||||
switch needed. All are geometry-time — applied on the next chart build via
|
||||
`init()`'s re-read.
|
||||
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms).
|
||||
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
|
||||
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
|
||||
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
node --test tests/*.test.js
|
||||
```
|
||||
|
||||
## Ported helpers (keep in sync with highway_3d)
|
||||
|
||||
Visual-parity code copied from `plugins/highway_3d/screen.js` — same
|
||||
function names, signatures, and constants on purpose, marked with
|
||||
`PORTED FROM highway_3d` comments at each site. If the guitar highway
|
||||
tunes one of these, mirror the change here (and in `drum_highway_3d`):
|
||||
|
||||
- `_bloomEnsure()` / `_bloomDispose()` — EffectComposer + UnrealBloomPass
|
||||
(0.65/0.5/0.82) on a multisampled HalfFloat target, ACES↔None tone-
|
||||
mapping switch in `draw()`; addons dynamic-imported from
|
||||
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
|
||||
graceful degrade)
|
||||
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
|
||||
(pool 96 here — the flame sprites carry most of the hit feedback)
|
||||
- `_timingHex()` / `_classifyTiming()` — early/late/on-time feedback
|
||||
colors (green/cyan/amber) + the 40%-window classifier
|
||||
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
|
||||
checks, which it needs for input routing and we don't)
|
||||
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
|
||||
as the guitar's, except `default` which is this plugin's original
|
||||
palette); one pick drives background gradient + floor + lane rails
|
||||
- `_makeStudioEnv()` — procedural PMREM studio environment (shared with
|
||||
drum_highway_3d; RoomEnvironment isn't vendored)
|
||||
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
|
||||
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
|
||||
`_bgReadBands()` — background ambience + the stems-first audio-analyser
|
||||
bridge (shared with drum_highway_3d)
|
||||
- `_drawScoreFx()` — score overlay (pops / tier rings / milestone bursts /
|
||||
streak-break wash), drum_highway_3d pattern
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0.
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="Keys Highway 3D placeholder thumbnail">
|
||||
<rect width="256" height="256" rx="36" fill="#0f172a"/>
|
||||
<rect x="18" y="18" width="220" height="220" rx="28" fill="#1e293b" stroke="#334155" stroke-width="3"/>
|
||||
<text x="128" y="150" font-family="Rubik, Arial, sans-serif" font-size="104" font-weight="800" fill="#38bdf8" text-anchor="middle">KH</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 464 B |
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.2.0",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "graphics"
|
||||
},
|
||||
"standards": [
|
||||
"capability-pipelines.v1",
|
||||
"plugin-runtime-idempotent.v1"
|
||||
],
|
||||
"capabilities": {
|
||||
"visualization": {
|
||||
"roles": [
|
||||
"provider"
|
||||
],
|
||||
"operations": [
|
||||
"renderer.create",
|
||||
"renderer.destroy"
|
||||
],
|
||||
"emits": [
|
||||
"renderer-ready",
|
||||
"renderer-failed"
|
||||
],
|
||||
"mode": "active",
|
||||
"safety": "safe"
|
||||
},
|
||||
"note-detection": {
|
||||
"roles": [
|
||||
"provider"
|
||||
],
|
||||
"operations": [
|
||||
"verify.target"
|
||||
],
|
||||
"emits": [
|
||||
"hit",
|
||||
"miss"
|
||||
],
|
||||
"mode": "active",
|
||||
"safety": "sensitive"
|
||||
},
|
||||
"midi-input": {
|
||||
"roles": [
|
||||
"requester"
|
||||
],
|
||||
"requests": [
|
||||
"discover",
|
||||
"list-sources",
|
||||
"select-source",
|
||||
"open-source",
|
||||
"close-source"
|
||||
],
|
||||
"mode": "active",
|
||||
"compatibility": "degrade-noop",
|
||||
"ownership": "requester-only",
|
||||
"safety": "sensitive",
|
||||
"description": "Reads the MIDI keyboard input through the core midi-input domain (Web-MIDI provider ships built-in with the domain). Absent domain \u2192 no MIDI devices, fail-soft.",
|
||||
"version": 1
|
||||
}
|
||||
},
|
||||
"category": "practice",
|
||||
"icon": "assets/thumb.svg"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
<div role="group" aria-labelledby="keysh3d-heading">
|
||||
<h3 id="keysh3d-heading" class="text-sm font-medium text-gray-400 mb-2">3D Keys Highway</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Pick a song with keys notation (sloppak-spec §5.3) and choose
|
||||
<em>Keys Highway 3D</em> from the viz picker (Auto selects it for
|
||||
notation charts). The plugin auto-attaches to your MIDI keyboard;
|
||||
device, channel and transpose are configured from the player for
|
||||
now — this panel holds the graphics controls.
|
||||
</p>
|
||||
|
||||
<!-- Graphics -->
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
|
||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
|
||||
<select id="keysh3d-fx-palette"
|
||||
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
|
||||
<option value="emerald">Emerald (green, darker sharps)</option>
|
||||
<option value="ice">Ice (blue, darker sharps)</option>
|
||||
<option value="classic">Rainbow (per-pitch)</option>
|
||||
<option value="vivid">Vivid (per-pitch, punchier)</option>
|
||||
<option value="pastel">Pastel (per-pitch, soft)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Choose the color scheme for the falling notes, key glow, lane
|
||||
guides and hit flames. Each option is described in its own label.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="keysh3d-fx-theme"
|
||||
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="default">Default (original)</option>
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="charcoal">Charcoal</option>
|
||||
<option value="deeppurple">Deep Purple</option>
|
||||
<option value="forest">Forest</option>
|
||||
<option value="warmslate">Warm Slate</option>
|
||||
<option value="deepfocus">Deep Focus</option>
|
||||
<option value="deepsea">Deep Sea</option>
|
||||
<option value="cathode">Cathode</option>
|
||||
<option value="cathodegreen">Cathode Green</option>
|
||||
<option value="hearth">Hearth</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background gradient, floor and lane rails — the same theme names
|
||||
as the guitar highway. Note colors come from the
|
||||
"Note colors" palette above.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||
<select id="keysh3d-fx-camera"
|
||||
onchange="window.keys3dSetCamera && window.keys3dSetCamera(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="classic">Classic (low, deep runway)</option>
|
||||
<option value="elevated">Elevated (higher, more board)</option>
|
||||
<option value="overhead" selected>Overhead (top-down reading view)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Where the camera sits. Classic is the original low rig; Elevated
|
||||
lifts it for a fuller view of the keybed; Overhead looks down the
|
||||
lanes for a sheet-reading feel. Applies live, keeps the
|
||||
auto-pan/zoom that follows your hands.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camheight"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Raise or lower the camera around the angle above (higher = more
|
||||
top-down). Fine-tunes the base view; the follow-motion stays.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camdist"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Pull the camera back or push it in (larger = further away, smaller
|
||||
= closer).
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camtilt"
|
||||
min="-1" max="1" step="0.02" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Tilt the view up (+) or down (−) without moving the camera —
|
||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||
</p>
|
||||
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
|
||||
|
||||
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps & flats</label>
|
||||
<select id="keysh3d-fx-sharpmode"
|
||||
onchange="window.keys3dSetSharpMode && window.keys3dSetSharpMode(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="floating">Floating</option>
|
||||
<option value="flat">Non-floating</option>
|
||||
<option value="realistic" selected>Realistic key sizes (default — best with no colored lanes)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How sharps and flats are drawn. <em>Floating</em>: they ride a raised
|
||||
plane above the naturals. <em>Non-floating</em>: everything on one
|
||||
plane, each key its own even piano-shaped lane. <em>Realistic key
|
||||
sizes</em>: one plane, bars sized like the real keys (full naturals,
|
||||
full black keys on top). Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-laneopacity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Lane color opacity <span id="keysh3d-fx-laneopacity-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-laneopacity"
|
||||
min="0" max="1" step="0.05" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('laneOpacity', this.value); document.getElementById('keysh3d-fx-laneopacity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly each lane is tinted its note color. 0.00 (default) is a
|
||||
dark floor with plain guide lines only between the key blocks (at E–F
|
||||
and each octave); the notes keep their colors and pop off the floor.
|
||||
Raise toward 1.00 for full, vivid colored lanes. Applies next time you
|
||||
open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavegaps" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-octavegaps" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('octaveGaps', this.checked)">
|
||||
Octave separators
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Widen the gap a little at each octave boundary (every B to the C
|
||||
above it) so octaves are easier to read. Applies next time you open
|
||||
a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavecontrast" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Octave line contrast <span id="keysh3d-fx-octavecontrast-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-octavecontrast"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('octaveContrast', this.value); document.getElementById('keysh3d-fx-octavecontrast-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly the octave line (every B to C) stands out. It adapts to
|
||||
the lane color opacity automatically — darkening the line against
|
||||
bright lanes and brightening it as you fade them toward the dark
|
||||
floor. Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="keysh3d-fx-cinematic" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
|
||||
Cinematic lighting
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Dimmer ambience, stronger key light — deeper shading on the keys
|
||||
and gems.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Glow strength <span id="keysh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-glow"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('glow', this.value); document.getElementById('keysh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How hard the notes, key glow and sustain consume-flash
|
||||
self-illuminate (0.5 = stock). Pairs with bloom.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
|
||||
<select id="keysh3d-fx-bgstyle"
|
||||
onchange="window.keys3dSetBgStyle && window.keys3dSetBgStyle(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="off">Off</option>
|
||||
<option value="particles" selected>Particles</option>
|
||||
<option value="lights">Stage lights</option>
|
||||
<option value="geometric">Geometric</option>
|
||||
</select>
|
||||
<label for="keysh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
|
||||
Ambience intensity <span id="keysh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-bgintensity"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('bgIntensity', this.value); document.getElementById('keysh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<label for="keysh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
|
||||
<input type="checkbox" id="keysh3d-fx-bgreactive" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('bgReactive', this.checked)">
|
||||
Audio-reactive ambience
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
The backdrop pulses with the mix (stems analyser when a sloppak
|
||||
is loaded). Off = it animates on time only.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-scorefx" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('scoreFx', this.checked)">
|
||||
Score effects
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
+1 pops on scored presses, a ring pulse every 10-combo,
|
||||
milestone bursts at 25/50/100, and a brief red flicker when a
|
||||
streak breaks.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-bloom" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('bloom', this.checked)">
|
||||
Glow (bloom)
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Soft light-bleed around the hit line, hit flames and consumed
|
||||
sustains. Applies live; turn off to reclaim GPU headroom on
|
||||
weak machines.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-sparks" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('sparks', this.checked)">
|
||||
Hit sparks
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
A small particle burst on every scored key press, beside the
|
||||
pitch-colored flame.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-timing" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
||||
Timing colors
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint the sparks by timing — on-time green, early cyan, late
|
||||
amber. Off = always green.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-streak" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('streakFx', this.checked)">
|
||||
Streak feedback
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Spark bursts grow with your combo.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Hit feedback intensity <span id="keysh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-hitfx"
|
||||
min="0" max="1" step="0.05" value="0.7"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('hitFx', this.value); document.getElementById('keysh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Drives the hit-line brightness kick on scored presses. 0 turns
|
||||
it off.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Note vibrancy <span id="keysh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-vibrancy"
|
||||
min="0" max="1" step="0.05" value="0.85"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('vibrancy', this.value); document.getElementById('keysh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Note-gem solidity and lane-guide strength — lower to see more of
|
||||
the keyboard through the notes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
// Hydrate controls from stored config on first paint. Narrow the
|
||||
// try/catch to just the localStorage reads (drum_highway_3d
|
||||
// settings-hydration convention).
|
||||
try {
|
||||
// FX toggles (keys3d_bg_* — guitar-parity graphics controls).
|
||||
// Only explicit values override; absent/corrupt keys keep the
|
||||
// default (ON), matching screen.js readFxSettings.
|
||||
const hydrateFxBool = (key, elId) => {
|
||||
const raw = localStorage.getItem('keys3d_bg_' + key);
|
||||
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
|
||||
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
|
||||
};
|
||||
hydrateFxBool('bloom', 'keysh3d-fx-bloom');
|
||||
hydrateFxBool('sparks', 'keysh3d-fx-sparks');
|
||||
hydrateFxBool('timingFx', 'keysh3d-fx-timing');
|
||||
hydrateFxBool('streakFx', 'keysh3d-fx-streak');
|
||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
||||
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
|
||||
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
|
||||
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const v = Math.min(1, Math.max(0, n));
|
||||
document.getElementById(elId).value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRange('hitFx', 'keysh3d-fx-hitfx', 'keysh3d-fx-hitfx-val');
|
||||
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
||||
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
|
||||
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
|
||||
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
|
||||
// Camera fine-tune sliders live outside 0-1 — clamp to the
|
||||
// control's own min/max (mirrors screen.js FX_RANGES).
|
||||
const hydrateFxRangeIn = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const el = document.getElementById(elId);
|
||||
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
|
||||
el.value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
|
||||
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
|
||||
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
|
||||
const storedCamera = localStorage.getItem('keys3d_bg_camera');
|
||||
const cameraSel = document.getElementById('keysh3d-fx-camera');
|
||||
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
|
||||
cameraSel.value = storedCamera;
|
||||
}
|
||||
const storedStyle = localStorage.getItem('keys3d_bg_style');
|
||||
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
styleSel.value = storedStyle;
|
||||
}
|
||||
const storedTheme = localStorage.getItem('keys3d_bg_theme');
|
||||
const themeSel = document.getElementById('keysh3d-fx-theme');
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
const storedPalette = localStorage.getItem('keys3d_bg_palette');
|
||||
const paletteSel = document.getElementById('keysh3d-fx-palette');
|
||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||
paletteSel.value = storedPalette;
|
||||
}
|
||||
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
|
||||
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
|
||||
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
|
||||
sharpSel.value = storedSharp;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
// Pure data-layer tests: load screen.js in a bare vm window and exercise the
|
||||
// __test exports (no DOM, no WebGL, no network).
|
||||
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');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return window.slopsmithViz_keys_highway_3d.__test;
|
||||
}
|
||||
|
||||
test('beatDurSec: base, dotted, double-dotted, tuplet', () => {
|
||||
const { beatDurSec } = load();
|
||||
// 120 BPM → quarter = 0.5s
|
||||
assert.equal(beatDurSec({ dur: 4 }, 120), 0.5);
|
||||
assert.equal(beatDurSec({ dur: 2 }, 120), 1.0);
|
||||
assert.equal(beatDurSec({ dur: 8 }, 120), 0.25);
|
||||
// dotted quarter = 0.75s; double-dotted = 0.875s
|
||||
assert.equal(beatDurSec({ dur: 4, dot: 1 }, 120), 0.75);
|
||||
assert.equal(beatDurSec({ dur: 4, dot: 2 }, 120), 0.875);
|
||||
// triplet eighth: 0.25 * 2/3
|
||||
assert.ok(Math.abs(beatDurSec({ dur: 8, tu: [3, 2] }, 120) - 0.25 * 2 / 3) < 1e-9);
|
||||
// invalid → null
|
||||
assert.equal(beatDurSec({ dur: 0 }, 120), null);
|
||||
assert.equal(beatDurSec({ dur: 4 }, null), null);
|
||||
});
|
||||
|
||||
function measure(idx, t, opts = {}) {
|
||||
return { idx, t, ...opts };
|
||||
}
|
||||
|
||||
test('flattenNotation: basic two-hand flatten, sorted, durSec from tempo', () => {
|
||||
const { flattenNotation } = load();
|
||||
const notes = flattenNotation([
|
||||
{
|
||||
idx: 1, t: 0, tempo: 120,
|
||||
staves: {
|
||||
rh: { voices: [{ v: 1, beats: [
|
||||
{ t: 0.5, dur: 4, notes: [{ midi: 64 }] },
|
||||
{ t: 1.0, dur: 8, notes: [{ midi: 67 }] },
|
||||
] }] },
|
||||
lh: { voices: [{ v: 1, beats: [
|
||||
{ t: 0.0, dur: 2, notes: [{ midi: 48 }] },
|
||||
] }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(notes.length, 3);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(notes.map(n => n.midi))), [48, 64, 67]); // time-sorted
|
||||
assert.equal(notes[0].hand, 'lh');
|
||||
assert.equal(notes[0].durSec, 1.0); // half at 120
|
||||
assert.equal(notes[1].durSec, 0.5); // quarter
|
||||
assert.equal(notes[2].durSec, 0.25); // eighth
|
||||
assert.equal(notes[1].measureIdx, 1);
|
||||
});
|
||||
|
||||
test('flattenNotation: tempo state carries across measures and changes apply', () => {
|
||||
const { flattenNotation } = load();
|
||||
const notes = flattenNotation([
|
||||
measure(1, 0, { tempo: 120, staves: { rh: { voices: [{ v: 1, beats: [{ t: 0, dur: 4, notes: [{ midi: 60 }] }] }] } } }),
|
||||
measure(2, 2, { staves: { rh: { voices: [{ v: 1, beats: [{ t: 2, dur: 4, notes: [{ midi: 62 }] }] }] } } }),
|
||||
measure(3, 4, { tempo: 60, staves: { rh: { voices: [{ v: 1, beats: [{ t: 4, dur: 4, notes: [{ midi: 64 }] }] }] } } }),
|
||||
]);
|
||||
assert.equal(notes[0].durSec, 0.5); // 120 BPM
|
||||
assert.equal(notes[1].durSec, 0.5); // tempo carried
|
||||
assert.equal(notes[2].durSec, 1.0); // 60 BPM
|
||||
});
|
||||
|
||||
test('flattenNotation: tied notes extend instead of emitting a new block', () => {
|
||||
const { flattenNotation } = load();
|
||||
const notes = flattenNotation([
|
||||
{
|
||||
idx: 1, t: 0, tempo: 120,
|
||||
staves: { rh: { voices: [{ v: 1, beats: [
|
||||
{ t: 0.0, dur: 2, notes: [{ midi: 60 }] },
|
||||
{ t: 1.0, dur: 2, notes: [{ midi: 60, tied: true }] },
|
||||
] }] } },
|
||||
},
|
||||
]);
|
||||
assert.equal(notes.length, 1);
|
||||
assert.equal(notes[0].durSec, 2.0); // half + tied half
|
||||
});
|
||||
|
||||
test('flattenNotation: no tempo anywhere falls back to next-onset gap', () => {
|
||||
const { flattenNotation } = load();
|
||||
const notes = flattenNotation([
|
||||
{
|
||||
idx: 1, t: 0,
|
||||
staves: { rh: { voices: [{ v: 1, beats: [
|
||||
{ t: 0.0, dur: 4, notes: [{ midi: 60 }] },
|
||||
{ t: 0.8, dur: 4, notes: [{ midi: 62 }] },
|
||||
] }] } },
|
||||
},
|
||||
]);
|
||||
assert.ok(Math.abs(notes[0].durSec - 0.8) < 1e-9);
|
||||
assert.equal(notes[1].durSec, 2.0); // final-beat fallback
|
||||
});
|
||||
|
||||
test('flattenNotation: overlap clamp against next same-hand same-midi onset', () => {
|
||||
const { flattenNotation } = load();
|
||||
const notes = flattenNotation([
|
||||
{
|
||||
idx: 1, t: 0, tempo: 30, // whole note = 8s — way past the next onset
|
||||
staves: { rh: { voices: [{ v: 1, beats: [
|
||||
{ t: 0.0, dur: 1, notes: [{ midi: 60 }] },
|
||||
{ t: 1.0, dur: 1, notes: [{ midi: 60 }] },
|
||||
] }] } },
|
||||
},
|
||||
]);
|
||||
assert.equal(notes[0].durSec, 1.0); // clamped to next onset
|
||||
});
|
||||
|
||||
test('flattenNotation: rests, malformed beats, and out-of-range midi are skipped', () => {
|
||||
const { flattenNotation } = load();
|
||||
const notes = flattenNotation([
|
||||
{
|
||||
idx: 1, t: 0, tempo: 120,
|
||||
staves: { rh: { voices: [{ v: 1, beats: [
|
||||
{ t: 0.0, dur: 4, rest: true },
|
||||
{ t: 0.5, dur: 4, notes: [{ midi: 200 }] },
|
||||
null,
|
||||
{ t: 1.0, dur: 4, notes: [{ midi: 64 }] },
|
||||
] }] } },
|
||||
},
|
||||
]);
|
||||
assert.equal(notes.length, 1);
|
||||
assert.equal(notes[0].midi, 64);
|
||||
});
|
||||
|
||||
test('keyRange pads and clamps to the 88-key piano, keeps active span explicit', () => {
|
||||
const { keyRange } = load();
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(keyRange([{ midi: 60 }, { midi: 72 }]))),
|
||||
{ low: 58, high: 74, activeLow: 60, activeHigh: 72 },
|
||||
);
|
||||
// At the clamp edges the active span still reflects the chart extremes
|
||||
// (not low+pad — that would mark A0/C8 inactive when actually played).
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(keyRange([{ midi: 21 }, { midi: 108 }]))),
|
||||
{ low: 21, high: 108, activeLow: 21, activeHigh: 108 },
|
||||
);
|
||||
const empty = keyRange([]);
|
||||
assert.ok(empty.low < 60 && empty.high > 60);
|
||||
assert.ok(empty.activeLow > empty.activeHigh, 'empty chart has an empty active span');
|
||||
});
|
||||
|
||||
test('noteLetter maps midi to pitch-class letters', () => {
|
||||
const { noteLetter } = load();
|
||||
assert.equal(noteLetter(60), 'C');
|
||||
assert.equal(noteLetter(61), 'C#');
|
||||
assert.equal(noteLetter(69), 'A');
|
||||
assert.equal(noteLetter(71), 'B');
|
||||
assert.equal(noteLetter(72), 'C'); // octave wraps
|
||||
assert.equal(noteLetter(21), 'A'); // A0
|
||||
});
|
||||
|
||||
test('scrollZ: events sit at hitZ exactly at their time and approach from -Z', () => {
|
||||
const { scrollZ } = load();
|
||||
const hitZ = -0.5, speed = 2.0;
|
||||
// At now === eventT the event is exactly on the hit-line.
|
||||
assert.equal(scrollZ(10, 10, hitZ, speed), hitZ);
|
||||
// 1s before its time it is `speed` units further away (towards -Z).
|
||||
assert.equal(scrollZ(10, 9, hitZ, speed), hitZ - speed);
|
||||
// After its time it has moved past the hit-line (towards +Z).
|
||||
assert.equal(scrollZ(10, 11, hitZ, speed), hitZ + speed);
|
||||
// Marker and note-front-edge maths agree by construction: a note of
|
||||
// length L positioned at scrollZ(t) - L/2 has its front edge at
|
||||
// scrollZ(t).
|
||||
const len = 0.8;
|
||||
assert.equal(scrollZ(10, 10, hitZ, speed) - len / 2 + len / 2, hitZ);
|
||||
});
|
||||
|
||||
test('measureMarkers extracts idx/t pairs', () => {
|
||||
const { measureMarkers } = load();
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(measureMarkers([{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }, { bogus: true }]))),
|
||||
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
// FX-settings scaffold tests (guitar-highway parity controls). Same bare-vm
|
||||
// harness as data_layer.test.js — no DOM, no localStorage — which doubles as
|
||||
// a lint that the new module-scope FX code stays side-effect safe.
|
||||
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');
|
||||
|
||||
function load(extraWindow) {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
...extraWindow,
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('readFxSettings: defaults survive a localStorage-less environment', () => {
|
||||
const { readFxSettings, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
|
||||
assert.equal(FX_DEFAULTS.bloom, true); // effects on by default
|
||||
});
|
||||
|
||||
test('readFxSettings: reads keys3d_bg_* overrides and coerces types', () => {
|
||||
const store = { keys3d_bg_bloom: '0' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readFxSettings } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readFxSettings().bloom, false);
|
||||
store.keys3d_bg_bloom = 'true';
|
||||
assert.equal(readFxSettings().bloom, true);
|
||||
store.keys3d_bg_bloom = 'false';
|
||||
assert.equal(readFxSettings().bloom, false);
|
||||
// Corrupt/foreign value → keep the default rather than silently
|
||||
// disabling the effect.
|
||||
store.keys3d_bg_bloom = 'banana';
|
||||
assert.equal(readFxSettings().bloom, true);
|
||||
});
|
||||
|
||||
test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetFx('bloom', false);
|
||||
assert.equal(store.keys3d_bg_bloom, '0');
|
||||
// String forms round-trip like the reader's accepted representations.
|
||||
win.keys3dSetFx('bloom', 'false');
|
||||
assert.equal(store.keys3d_bg_bloom, '0');
|
||||
win.keys3dSetFx('bloom', 'true');
|
||||
assert.equal(store.keys3d_bg_bloom, '1');
|
||||
win.keys3dSetFx('bloom', false);
|
||||
assert.equal(events.length, 4);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
// Field-wise (the detail object was built inside the vm realm, so a
|
||||
// deep-strict compare would trip on its foreign Object.prototype).
|
||||
assert.equal(events[0].detail.fx.bloom, false);
|
||||
assert.deepEqual(Object.keys(events[0].detail.fx), ['bloom']);
|
||||
// Unknown key: no write, no event.
|
||||
win.keys3dSetFx('nonsense', 1);
|
||||
assert.equal(events.length, 4);
|
||||
assert.ok(!('keys3d_bg_nonsense' in store));
|
||||
});
|
||||
|
||||
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
|
||||
const { _classifyTiming } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const tol = 0.10; // keys HIT_TOLERANCE_S
|
||||
assert.equal(_classifyTiming(0, tol), 'OK');
|
||||
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK');
|
||||
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
|
||||
// delta = note.t - now: positive → struck before the note → EARLY.
|
||||
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
|
||||
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
|
||||
assert.equal(_classifyTiming(NaN, tol), 'OK');
|
||||
});
|
||||
|
||||
test('noteKey prefix round-trips the matched note time (timing-delta source)', () => {
|
||||
const { noteKey } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
// _checkHit derives the timing delta as parseFloat(judgeHit's key) - t;
|
||||
// this pins the serialization that makes that recovery valid.
|
||||
assert.equal(parseFloat(noteKey(12.3456, 60)), 12.346);
|
||||
assert.equal(parseFloat(noteKey(0, 21)), 0);
|
||||
});
|
||||
|
||||
test('FX defaults: hit-FX + vibrancy controls ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.sparks, true);
|
||||
assert.equal(FX_DEFAULTS.timingFx, true);
|
||||
assert.equal(FX_DEFAULTS.streakFx, true);
|
||||
assert.equal(FX_DEFAULTS.hitFx, 0.7);
|
||||
assert.equal(FX_DEFAULTS.vibrancy, 0.85);
|
||||
});
|
||||
|
||||
test('themes: table ids match the guitar highway, default is the stock palette', () => {
|
||||
const { BG_THEMES, _bgThemeColors, readThemeSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(BG_THEMES), [
|
||||
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
|
||||
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
|
||||
]);
|
||||
// 'default' preserves THIS plugin's original look.
|
||||
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
|
||||
assert.equal(BG_THEMES.default.board, 0x141422);
|
||||
assert.equal(BG_THEMES.default.laneDim, 0x2a2a3e);
|
||||
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
|
||||
assert.equal(readThemeSetting(), 'default'); // no localStorage in the vm
|
||||
for (const [id, t] of Object.entries(BG_THEMES)) {
|
||||
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
|
||||
assert.ok(t.laneDim != null, id + ' rail color');
|
||||
}
|
||||
});
|
||||
|
||||
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.cinematic, true);
|
||||
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
|
||||
});
|
||||
|
||||
|
||||
test('bg styles: validated id set, particles default', () => {
|
||||
const { BG_STYLE_IDS, readBgStyleSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
|
||||
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
|
||||
assert.equal(readBgStyleSetting(), 'particles');
|
||||
});
|
||||
|
||||
test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.scoreFx, true);
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
|
||||
|
||||
test('note palettes: 12 entries each, classic IS the stock table', () => {
|
||||
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
|
||||
load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(NOTE_PALETTES),
|
||||
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
|
||||
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
|
||||
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
|
||||
for (const c of colors) {
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
|
||||
id + ' colours are 24-bit ints');
|
||||
}
|
||||
}
|
||||
// 'classic' preserves the shipped look byte-identically — it is the
|
||||
// same array, not a copy that could drift.
|
||||
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
|
||||
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
|
||||
});
|
||||
|
||||
test('note palettes: two-tone tables use darker sharps than naturals', () => {
|
||||
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
for (const id of ['emerald', 'ice']) {
|
||||
const p = NOTE_PALETTES[id];
|
||||
for (const sharp of [1, 3, 6, 8, 10]) {
|
||||
assert.ok(luma(p[sharp]) < luma(p[0]),
|
||||
id + ' sharp pc ' + sharp + ' darker than naturals');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readPaletteSetting: octaves default, validated overrides only', () => {
|
||||
// No localStorage in the vm → the plug-and-play default.
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(bare.readPaletteSetting(), 'octaves');
|
||||
// An explicit non-default value (classic) overrides.
|
||||
const store = { keys3d_bg_palette: 'classic' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readPaletteSetting(), 'classic');
|
||||
// Corrupt/foreign value → the default rather than an undefined scheme.
|
||||
store.keys3d_bg_palette = 'banana';
|
||||
assert.equal(readPaletteSetting(), 'octaves');
|
||||
});
|
||||
|
||||
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetPalette('emerald');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.palette, 'emerald');
|
||||
// Unknown id: no write, no event.
|
||||
win.keys3dSetPalette('banana');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
|
||||
win.keys3dSetPalette('octaves');
|
||||
assert.equal(store.keys3d_bg_palette, 'octaves');
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
|
||||
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...PALETTE_IDS],
|
||||
[...Object.keys(NOTE_PALETTES), 'octaves']);
|
||||
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
|
||||
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
|
||||
});
|
||||
|
||||
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
|
||||
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
|
||||
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
|
||||
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
|
||||
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
|
||||
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
|
||||
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
|
||||
// Naturals across one octave (C1..B1 whites) all share the octave hue.
|
||||
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
|
||||
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
|
||||
}
|
||||
// Sharps in an octave are a DARKER shade of that same hue.
|
||||
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
|
||||
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
|
||||
'sharp ' + sharp + ' darker than the octave natural');
|
||||
}
|
||||
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
|
||||
// different from the red octave-1 start.
|
||||
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
|
||||
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
|
||||
// Loop: an octave past the table wraps (safety for out-of-88 midi).
|
||||
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
|
||||
});
|
||||
|
||||
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
|
||||
|
||||
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
|
||||
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.camHeight, 1.0);
|
||||
assert.equal(FX_DEFAULTS.camDist, 1.0);
|
||||
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
|
||||
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
|
||||
// and 'classic' + this default reproduces the exact historical rig.
|
||||
assert.equal(FX_DEFAULTS.camTilt, 0.0);
|
||||
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
|
||||
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
|
||||
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
|
||||
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
|
||||
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
|
||||
});
|
||||
|
||||
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
win.keys3dSetFx('camTilt', -0.5);
|
||||
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
|
||||
win.keys3dSetFx('camTilt', -99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
|
||||
});
|
||||
|
||||
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
|
||||
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
|
||||
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
|
||||
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
|
||||
// Setter: same clamp on the way in; a value above 1 must survive
|
||||
// (the historical 0-1 clamp would have crushed 1.3 to 1).
|
||||
win.keys3dSetFx('camHeight', 1.3);
|
||||
assert.equal(store.keys3d_bg_camHeight, '1.3');
|
||||
win.keys3dSetFx('camDist', 99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
|
||||
// Un-ranged keys keep the historical 0-1 clamp.
|
||||
win.keys3dSetFx('vibrancy', 2);
|
||||
assert.equal(store.keys3d_bg_vibrancy, '1');
|
||||
});
|
||||
|
||||
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
|
||||
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const hitZ = 0;
|
||||
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
|
||||
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
|
||||
assert.equal(d2, d1 * 2);
|
||||
// At the hit moment the note is at the hit-line regardless of speed.
|
||||
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
|
||||
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
|
||||
});
|
||||
|
||||
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
|
||||
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
|
||||
// 'classic' preserves the historical constants (pre-K units) even though
|
||||
// it is no longer the default — anyone who picks it gets the old rig back
|
||||
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
|
||||
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
|
||||
assert.deepEqual({ ...CAM_PRESETS.classic },
|
||||
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
|
||||
for (const [id, p] of Object.entries(CAM_PRESETS)) {
|
||||
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
|
||||
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
|
||||
}
|
||||
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
|
||||
}
|
||||
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
|
||||
});
|
||||
|
||||
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
|
||||
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
|
||||
// The shipped default look = overhead preset + the default camTilt. Before,
|
||||
// that was lookY 0 + (−0.6 × 55) = −33; the tuned aim now lives in the
|
||||
// preset (lookY −33) with a neutral camTilt (0), so the effective aim — and
|
||||
// thus the out-of-the-box framing — is byte-identical.
|
||||
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effOverhead, -33);
|
||||
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
|
||||
// the "pick Classic for the original look" promise, now actually true.
|
||||
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effClassic, 8);
|
||||
});
|
||||
|
||||
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetCamera('overhead');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.camera, 'overhead');
|
||||
win.keys3dSetCamera('helicopter');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
store.keys3d_bg_camera = 'garbage';
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
});
|
||||
|
||||
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
|
||||
|
||||
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
|
||||
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
|
||||
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
|
||||
// Sharp LAYOUT is a string setting, not an FX bool.
|
||||
assert.equal('flatSharps' in FX_DEFAULTS, false);
|
||||
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
|
||||
});
|
||||
|
||||
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetFx('octaveGaps', true);
|
||||
assert.equal(store.keys3d_bg_octaveGaps, '1');
|
||||
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
|
||||
win.keys3dSetFx('laneOpacity', 0.35);
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
|
||||
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '1');
|
||||
win.keys3dSetFx('octaveContrast', 0.8);
|
||||
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
|
||||
});
|
||||
|
||||
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
|
||||
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events[0].detail.sharpMode, 'flat');
|
||||
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
|
||||
// Unknown id ignored (no write, no event).
|
||||
win.keys3dSetSharpMode('bogus');
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
|
||||
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const sh = 2.2, shift = 2.2 / 3;
|
||||
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
|
||||
// cx for one octave: whites on integer slots, blacks on half-slots — the
|
||||
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
|
||||
const CX = {
|
||||
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
|
||||
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
|
||||
};
|
||||
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
|
||||
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
|
||||
const wOf = (s) => s.right - s.left;
|
||||
const w = (m) => wOf(spans[midis.indexOf(m)]);
|
||||
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
|
||||
for (let i = 1; i < spans.length; i++) {
|
||||
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
|
||||
}
|
||||
// Sharps are all the same width.
|
||||
for (const m of [61, 63, 66, 68, 70]) {
|
||||
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
|
||||
}
|
||||
// The lean evens the naturals: C, D, E, F, B all come out equal.
|
||||
for (const m of [62, 64, 65, 71]) {
|
||||
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
|
||||
}
|
||||
// G and A are the only slightly-smaller naturals (G# can't lean) — still
|
||||
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
|
||||
// (which would leave D at 12−2·sh, far below C's 12−sh).
|
||||
assert.ok(Math.abs(w(67) - w(69)) < 1e-9, 'G == A');
|
||||
assert.ok(w(67) < w(60) && w(67) > 2 * sh, 'G/A a touch smaller, still wider than a sharp');
|
||||
assert.ok(w(60) - w(67) < sh, 'natural spread is under one sharp-width');
|
||||
});
|
||||
|
||||
test('laneSpanReal (V4): naturals uniform, sharps full-width and overlapping', () => {
|
||||
const { laneSpanReal } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { natHalf: 5.64, sharpHalf: 3.2, octGap: 0.9 }; // mirrors LANE_DIMS_REAL
|
||||
const wOf = (s) => s.right - s.left;
|
||||
// Every natural is the same full width, whatever its neighbours.
|
||||
for (const [midi, slot] of [[60, 0], [62, 1], [64, 2], [67, 4], [71, 6]]) {
|
||||
assert.ok(Math.abs(wOf(laneSpanReal(midi, false, slot * 12, dims, false)) - 2 * 5.64) < 1e-9,
|
||||
'natural ' + midi + ' uniform');
|
||||
}
|
||||
// Sharps are the full (wider) black-key width and overlap their naturals.
|
||||
const C = laneSpanReal(60, false, 0, dims, false);
|
||||
const Cs = laneSpanReal(61, true, 6, dims, false);
|
||||
assert.ok(Math.abs(wOf(Cs) - 2 * 3.2) < 1e-9, 'sharp full width');
|
||||
assert.ok(Cs.left < C.right, 'sharp overlaps (tucks over) the natural');
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): octaveGaps widens B→C by octGap, sharps unaffected', () => {
|
||||
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 };
|
||||
const gapOff = laneSpanFlat(72, false, 84, dims, false).left - laneSpanFlat(71, false, 72, dims, false).right;
|
||||
const gapOn = laneSpanFlat(72, false, 84, dims, true).left - laneSpanFlat(71, false, 72, dims, true).right;
|
||||
assert.ok(Math.abs((gapOn - gapOff) - dims.octGap) < 1e-9, 'B→C divider grows by octGap');
|
||||
// Sharps are unaffected by the octave-gap option.
|
||||
const s = laneSpanFlat(61, true, 6, dims, true);
|
||||
assert.ok(Math.abs((s.right - s.left) - 2 * dims.sharpHalf) < 1e-9, 'sharp width unchanged by gaps');
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): active-range boundary key is NOT trimmed by an out-of-range neighbor sharp', () => {
|
||||
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 }; // mirrors LANE_DIMS_FLAT
|
||||
// F (midi 65, cx 36): its upper neighbor F# (66) is a sharp. When F sits
|
||||
// at range.activeHigh and F# is excluded from the active range, F# never
|
||||
// gets a lane drawn (see the activeLow/activeHigh skip around the
|
||||
// lane-strip loop) — trimming F's right edge for it would leave a dark,
|
||||
// unfilled sliver. The edge should stay full instead.
|
||||
const highBoundary = { activeLow: 60, activeHigh: 65 };
|
||||
const fAtBoundary = laneSpanFlat(65, false, 36, dims, false, highBoundary);
|
||||
assert.ok(Math.abs(fAtBoundary.right - (36 + dims.whiteW / 2)) < 1e-9,
|
||||
'F right edge stays full when F# is out of the active range');
|
||||
// Same key, but now F# IS in the active range: normal zero-overlap
|
||||
// tiling applies — the trim matches the ungated (no-range) call exactly,
|
||||
// so in-range geometry is unaffected by this fix.
|
||||
const highIncluded = { activeLow: 60, activeHigh: 66 };
|
||||
const fWithSharpInRange = laneSpanFlat(65, false, 36, dims, false, highIncluded);
|
||||
const fUngated = laneSpanFlat(65, false, 36, dims, false);
|
||||
assert.ok(Math.abs(fWithSharpInRange.right - fUngated.right) < 1e-9,
|
||||
'F trims normally once F# is back in range');
|
||||
assert.ok(fWithSharpInRange.right < fAtBoundary.right, 'in-range trim is narrower than the boundary full edge');
|
||||
|
||||
// Symmetric case on the low edge: D (midi 62, cx 12), lower neighbor C#
|
||||
// (61) excluded when D sits at range.activeLow.
|
||||
const lowBoundary = { activeLow: 62, activeHigh: 72 };
|
||||
const dAtBoundary = laneSpanFlat(62, false, 12, dims, false, lowBoundary);
|
||||
assert.ok(Math.abs(dAtBoundary.left - (12 - dims.whiteW / 2)) < 1e-9,
|
||||
'D left edge stays full when C# is out of the active range');
|
||||
const lowIncluded = { activeLow: 61, activeHigh: 72 };
|
||||
const dWithSharpInRange = laneSpanFlat(62, false, 12, dims, false, lowIncluded);
|
||||
const dUngated = laneSpanFlat(62, false, 12, dims, false);
|
||||
assert.ok(Math.abs(dWithSharpInRange.left - dUngated.left) < 1e-9,
|
||||
'D trims normally once C# is back in range');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Pure MIDI-scoring tests: load screen.js in a bare vm window and exercise
|
||||
// the __test exports (no DOM, no WebGL, no MIDI device, no network).
|
||||
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');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return window.slopsmithViz_keys_highway_3d.__test;
|
||||
}
|
||||
|
||||
const TOL = 0.10;
|
||||
|
||||
test('accuracyOf/scoreOf mirror the notedetect stats formula', () => {
|
||||
const { accuracyOf, scoreOf } = load();
|
||||
// accuracy = hits / max(1, hits + misses)
|
||||
assert.equal(accuracyOf(0, 0), 0);
|
||||
assert.equal(accuracyOf(10, 0), 1);
|
||||
assert.equal(accuracyOf(3, 1), 0.75);
|
||||
// score = round(hits * 100 * accuracy)
|
||||
assert.equal(scoreOf(0, 0), 0);
|
||||
assert.equal(scoreOf(10, 0), 1000);
|
||||
assert.equal(scoreOf(3, 1), Math.round(3 * 100 * 0.75));
|
||||
// Monotonic in accuracy at fixed hits.
|
||||
assert.ok(scoreOf(5, 0) > scoreOf(5, 5));
|
||||
});
|
||||
|
||||
test('judgeHit: exact note inside ±0.10 s window hits; outside misses', () => {
|
||||
const { judgeHit } = load();
|
||||
const notes = [
|
||||
{ midi: 60, t: 1.0 },
|
||||
{ midi: 64, t: 1.0 },
|
||||
{ midi: 62, t: 2.0 },
|
||||
];
|
||||
const hitKeys = new Set();
|
||||
// On time.
|
||||
assert.equal(judgeHit(notes, 60, 1.0, hitKeys, TOL), '1.000|60');
|
||||
// Near the edge of the window (the exact ±0.10 boundary is float-
|
||||
// representation dependent, same as the piano plugin).
|
||||
assert.equal(judgeHit(notes, 62, 2.099, hitKeys, TOL), '2.000|62');
|
||||
assert.equal(judgeHit(notes, 64, 0.901, hitKeys, TOL), '1.000|64');
|
||||
// Just outside the window.
|
||||
assert.equal(judgeHit(notes, 60, 1.11, hitKeys, TOL), null);
|
||||
// Wrong note — nothing at that midi anywhere near.
|
||||
assert.equal(judgeHit(notes, 65, 1.0, hitKeys, TOL), null);
|
||||
});
|
||||
|
||||
test('judgeHit: dedupes by t|midi — a chart note can only be hit once', () => {
|
||||
const { judgeHit } = load();
|
||||
const notes = [{ midi: 60, t: 1.0 }, { midi: 60, t: 1.15 }];
|
||||
const hitKeys = new Set();
|
||||
const first = judgeHit(notes, 60, 1.02, hitKeys, TOL);
|
||||
assert.equal(first, '1.000|60');
|
||||
hitKeys.add(first);
|
||||
// Second strike near the same time falls through to the NEXT un-hit
|
||||
// chart note at the same midi (double-stop repeats).
|
||||
const second = judgeHit(notes, 60, 1.06, hitKeys, TOL);
|
||||
assert.equal(second, '1.150|60');
|
||||
hitKeys.add(second);
|
||||
// Third strike: both consumed → wrong note.
|
||||
assert.equal(judgeHit(notes, 60, 1.1, hitKeys, TOL), null);
|
||||
});
|
||||
|
||||
test('judgeHit: empty/absent chart never judges', () => {
|
||||
const { judgeHit } = load();
|
||||
assert.equal(judgeHit([], 60, 1.0, new Set(), TOL), null);
|
||||
assert.equal(judgeHit(null, 60, 1.0, new Set(), TOL), null);
|
||||
});
|
||||
|
||||
test('sweepMissed: marks elapsed unhit notes once, respects hit + floor', () => {
|
||||
const { sweepMissed, noteKey } = load();
|
||||
const notes = [
|
||||
{ midi: 60, t: 1.0 },
|
||||
{ midi: 62, t: 1.5 },
|
||||
{ midi: 64, t: 5.0 },
|
||||
];
|
||||
const hitKeys = new Set([noteKey(1.0, 60)]); // 60@1.0 was hit
|
||||
const missedKeys = new Set();
|
||||
const missed = [];
|
||||
// At t=2.0 the windows for 1.0 and 1.5 have elapsed; 5.0 is pending.
|
||||
const n1 = sweepMissed(notes, 2.0, hitKeys, missedKeys, TOL, null, n => missed.push(n.midi));
|
||||
assert.equal(n1, 1);
|
||||
assert.deepEqual(missed, [62]);
|
||||
assert.ok(missedKeys.has(noteKey(1.5, 62)));
|
||||
// Sweeping again counts nothing new (idempotent per note).
|
||||
assert.equal(sweepMissed(notes, 2.1, hitKeys, missedKeys, TOL, null), 0);
|
||||
// Floor: a device connected at t=6 must not retro-miss the 5.0 note.
|
||||
const hk2 = new Set(), mk2 = new Set();
|
||||
assert.equal(sweepMissed(notes, 6.0, hk2, mk2, TOL, 6.0), 0);
|
||||
});
|
||||
|
||||
test('sweepMissed: a note exactly at the connect floor is not retro-missed', () => {
|
||||
// Off-by-one guard: floor is the connect instant; a note whose onset
|
||||
// equals it (device connected exactly as the onset passed) must be
|
||||
// excluded, not swept. Floor comparison is `<=`, not `<`.
|
||||
const { sweepMissed } = load();
|
||||
const notes = [{ midi: 60, t: 5.0 }, { midi: 62, t: 6.0 }];
|
||||
const missedKeys = new Set();
|
||||
const missed = [];
|
||||
const n = sweepMissed(notes, 7.0, new Set(), missedKeys, TOL, 5.0,
|
||||
m => missed.push(m.midi));
|
||||
assert.equal(n, 1);
|
||||
assert.deepEqual(missed, [62]);
|
||||
});
|
||||
|
||||
test('sweepMissed: a long frame stall cannot let elapsed notes slip past', () => {
|
||||
const { sweepMissed } = load();
|
||||
const notes = [{ midi: 60, t: 1.0 }, { midi: 62, t: 3.0 }];
|
||||
const missedKeys = new Set();
|
||||
// The previous sweep ran at t≈0; the next runs 10 s later (backgrounded
|
||||
// tab / render hitch). Both elapsed notes must still be counted.
|
||||
assert.equal(sweepMissed(notes, 10.0, new Set(), missedKeys, TOL, null), 2);
|
||||
});
|
||||
|
||||
test('sweepMissed: cursor advances monotonically and never recounts', () => {
|
||||
const { sweepMissed } = load();
|
||||
const notes = [
|
||||
{ midi: 60, t: 1.0 },
|
||||
{ midi: 62, t: 2.0 },
|
||||
{ midi: 64, t: 9.0 },
|
||||
];
|
||||
const hitKeys = new Set(), missedKeys = new Set();
|
||||
const cursor = { idx: 0 };
|
||||
assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 1);
|
||||
assert.equal(cursor.idx, 1);
|
||||
// Stall to t=8: the 2.0 note is counted exactly once from the cursor.
|
||||
assert.equal(sweepMissed(notes, 8.0, hitKeys, missedKeys, TOL, null, null, cursor), 1);
|
||||
assert.equal(cursor.idx, 2);
|
||||
// Seek BACKWARDS: the cursor does not rewind, nothing is recounted.
|
||||
assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 0);
|
||||
// The cursor still advances past pre-floor notes without counting them.
|
||||
const c2 = { idx: 0 };
|
||||
const mk2 = new Set();
|
||||
assert.equal(sweepMissed(notes, 8.0, new Set(), mk2, TOL, 5.0, null, c2), 0);
|
||||
assert.equal(c2.idx, 2);
|
||||
});
|
||||
|
||||
test('noteKey quantises time to ms so float drift cannot double-count', () => {
|
||||
const { noteKey } = load();
|
||||
assert.equal(noteKey(1.0004, 60), noteKey(1.0001, 60));
|
||||
assert.notEqual(noteKey(1.002, 60), noteKey(1.0001, 60));
|
||||
assert.notEqual(noteKey(1.0, 60), noteKey(1.0, 61));
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.4",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
+10
-14
@@ -33,10 +33,9 @@ def setup(app: FastAPI, context: dict):
|
||||
"lastInstrument": DEFAULT_INSTRUMENT,
|
||||
"freeTune": False,
|
||||
"customTunings": {},
|
||||
"disabledTunings": [],
|
||||
"showFloatingButton": True,
|
||||
"visualizationMode": "default",
|
||||
"audioInputMode": "auto",
|
||||
"autoOpenOnTuningChange": False,
|
||||
}
|
||||
if not config_file.exists():
|
||||
return defaults
|
||||
@@ -50,16 +49,17 @@ def setup(app: FastAPI, context: dict):
|
||||
res["lastInstrument"] = str(data.get("lastInstrument", DEFAULT_INSTRUMENT))
|
||||
res["freeTune"] = bool(data.get("freeTune", False))
|
||||
res["customTunings"] = data.get("customTunings", {})
|
||||
res["disabledTunings"] = data.get("disabledTunings", [])
|
||||
res["showFloatingButton"] = bool(data.get("showFloatingButton", True))
|
||||
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
|
||||
raw_mode = str(data.get("audioInputMode", "auto"))
|
||||
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
|
||||
# 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"] = {}
|
||||
if not isinstance(res["disabledTunings"], list):
|
||||
res["disabledTunings"] = []
|
||||
|
||||
# Migrate custom tunings from old flat-list format
|
||||
res["customTunings"] = {
|
||||
@@ -67,12 +67,6 @@ def setup(app: FastAPI, context: dict):
|
||||
for name, val in res["customTunings"].items()
|
||||
}
|
||||
|
||||
# Strip legacy disabledTunings entries that lack compound "instrument:name" format
|
||||
res["disabledTunings"] = [
|
||||
e for e in res["disabledTunings"]
|
||||
if isinstance(e, str) and ":" in e
|
||||
]
|
||||
|
||||
return res
|
||||
except Exception:
|
||||
return defaults
|
||||
@@ -80,8 +74,10 @@ def setup(app: FastAPI, context: dict):
|
||||
def _write(data: dict) -> None:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
current = _read()
|
||||
# Strip keys that belong to core, not to this plugin's config.
|
||||
for key in ("defaultTunings", "referencePitch"):
|
||||
# Strip keys that belong to core, not to this plugin's config, plus
|
||||
# retired keys (disabledTunings/showFloatingButton — their settings UI
|
||||
# was removed) so a stale client can't re-persist them.
|
||||
for key in ("defaultTunings", "referencePitch", "disabledTunings", "showFloatingButton"):
|
||||
data = {k: v for k, v in data.items() if k != key}
|
||||
current.update(data)
|
||||
config_file.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
||||
|
||||
+443
-21
@@ -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 = {
|
||||
@@ -33,7 +42,6 @@
|
||||
_allTunings: {},
|
||||
referencePitch: 440,
|
||||
visualizationMode: 'default',
|
||||
showFloatingButton: true,
|
||||
currentSongOffsets: null,
|
||||
currentSongIsBass: false,
|
||||
currentSongStringCount: 0,
|
||||
@@ -83,10 +91,6 @@
|
||||
}
|
||||
|
||||
// ── Tuning helpers ────────────────────────────────────────────────
|
||||
function _isTuningEnabled(instrument, name) {
|
||||
return !((_state._serverConfig ? _state._serverConfig.disabledTunings : null) || []).includes(instrument + ':' + name);
|
||||
}
|
||||
|
||||
function _instrumentForTuning(name) {
|
||||
for (var key in _state._allTunings) {
|
||||
if (_state._allTunings[key] && _state._allTunings[key][name]) return key;
|
||||
@@ -95,11 +99,7 @@
|
||||
}
|
||||
|
||||
function _buildTuningsForInstrument(instrument) {
|
||||
const all = _state._allTunings[instrument] || {};
|
||||
const disabled = (_state._serverConfig ? _state._serverConfig.disabledTunings : null) || [];
|
||||
return Object.fromEntries(
|
||||
Object.entries(all).filter(([name]) => !disabled.includes(instrument + ':' + name))
|
||||
);
|
||||
return { ...(_state._allTunings[instrument] || {}) };
|
||||
}
|
||||
|
||||
function _tuningIdentityKey(songInfo) {
|
||||
@@ -133,15 +133,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 +409,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 +438,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 +481,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;
|
||||
@@ -267,7 +545,6 @@
|
||||
_state._serverConfig = config;
|
||||
_state._allTunings = tuningsData.tunings || {};
|
||||
_state.referencePitch = tuningsData.referencePitch || 440;
|
||||
_state.showFloatingButton = config.showFloatingButton !== false;
|
||||
_state.visualizationMode = config.visualizationMode || 'default';
|
||||
_state.audioInputMode = config.audioInputMode || 'auto';
|
||||
|
||||
@@ -341,8 +618,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 +656,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 +699,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 +713,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 +740,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 +794,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 {
|
||||
|
||||
+11
-137
@@ -1,11 +1,11 @@
|
||||
<div class="space-y-6 py-2">
|
||||
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-200">Floating Button</h3>
|
||||
<p class="text-[11px] text-gray-500">Show the tuner button on the main interface.</p>
|
||||
<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-show-floating" class="sr-only peer" onchange="window._tunerToggleFloating(this.checked)">
|
||||
<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>
|
||||
@@ -27,14 +27,6 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Tuning Visibility</h3>
|
||||
<p class="text-xs text-gray-500 mb-4">Toggle which built-in tunings appear in the tuner menu.</p>
|
||||
<div id="tuner-visibility-list" class="space-y-2 pr-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Custom Tunings</h3>
|
||||
<div id="tuner-custom-list" class="space-y-2 mb-4">
|
||||
<!-- Populated by JS -->
|
||||
@@ -77,16 +69,7 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
let config = { customTunings: {}, disabledTunings: [], defaultTunings: {}, showFloatingButton: true, audioInputMode: 'auto' };
|
||||
let expandedGroups = [];
|
||||
|
||||
var _INSTRUMENT_CAPTIONS = {
|
||||
"guitar-6": "Guitar",
|
||||
"guitar-7": "Guitar 7-string",
|
||||
"guitar-8": "Guitar 8-string",
|
||||
"bass-4": "Bass 4-string",
|
||||
"bass-5": "Bass 5-string"
|
||||
};
|
||||
let config = { customTunings: {}, audioInputMode: 'auto' };
|
||||
|
||||
var _instrumentLabels = {
|
||||
"guitar-6": "Guitar 6-string",
|
||||
@@ -101,23 +84,23 @@
|
||||
const resp = await fetch('/api/plugins/tuner/config');
|
||||
config = await resp.json();
|
||||
|
||||
const floatingToggle = document.getElementById('tuner-show-floating');
|
||||
if (floatingToggle) floatingToggle.checked = config.showFloatingButton !== false;
|
||||
|
||||
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
|
||||
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
|
||||
|
||||
const autoOpenToggle = document.getElementById('tuner-auto-open');
|
||||
if (autoOpenToggle) autoOpenToggle.checked = config.autoOpenOnTuningChange === true;
|
||||
|
||||
render();
|
||||
} catch (e) { console.error('Tuner settings: load failed', e); }
|
||||
}
|
||||
|
||||
window._tunerToggleFloating = (enabled) => {
|
||||
config.showFloatingButton = enabled;
|
||||
window._tunerToggleBrowserAudio = (forceBrowser) => {
|
||||
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
|
||||
save();
|
||||
};
|
||||
|
||||
window._tunerToggleBrowserAudio = (forceBrowser) => {
|
||||
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
|
||||
window._tunerToggleAutoOpen = (enabled) => {
|
||||
config.autoOpenOnTuningChange = enabled;
|
||||
save();
|
||||
};
|
||||
|
||||
@@ -134,115 +117,6 @@
|
||||
}
|
||||
|
||||
function render() {
|
||||
const visList = document.getElementById('tuner-visibility-list');
|
||||
visList.innerHTML = '';
|
||||
|
||||
const defaultTunings = config.defaultTunings || {};
|
||||
|
||||
Object.keys(defaultTunings).forEach(groupName => {
|
||||
const group = defaultTunings[groupName];
|
||||
const groupTunings = Object.keys(group);
|
||||
const instrument = groupName;
|
||||
// Compound keys for all tunings in this group
|
||||
const compoundKeys = groupTunings.map(n => instrument + ':' + n);
|
||||
|
||||
const groupWrapper = document.createElement('div');
|
||||
groupWrapper.className = 'mb-4';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-center justify-between p-2 mt-2 bg-dark-900/80 rounded-t-lg border-x border-t border-gray-800/50 cursor-pointer hover:bg-dark-900 transition-colors';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'flex items-center gap-2';
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>';
|
||||
chevron.className = 'text-gray-500 transition-transform duration-200 rotate-0';
|
||||
|
||||
const groupLabel = document.createElement('span');
|
||||
groupLabel.className = 'text-[11px] font-bold text-gray-400 uppercase tracking-wider';
|
||||
groupLabel.textContent = _INSTRUMENT_CAPTIONS[groupName] || groupName;
|
||||
|
||||
left.appendChild(chevron);
|
||||
left.appendChild(groupLabel);
|
||||
|
||||
const groupToggle = document.createElement('input');
|
||||
groupToggle.type = 'checkbox';
|
||||
const allEnabled = compoundKeys.every(k => !config.disabledTunings.includes(k));
|
||||
const someEnabled = compoundKeys.some(k => !config.disabledTunings.includes(k));
|
||||
groupToggle.checked = allEnabled;
|
||||
groupToggle.indeterminate = someEnabled && !allEnabled;
|
||||
groupToggle.className = 'accent-accent';
|
||||
|
||||
groupToggle.onclick = (e) => e.stopPropagation();
|
||||
groupToggle.onchange = () => {
|
||||
if (groupToggle.checked) {
|
||||
config.disabledTunings = config.disabledTunings.filter(k => !compoundKeys.includes(k));
|
||||
} else {
|
||||
compoundKeys.forEach(k => {
|
||||
if (!config.disabledTunings.includes(k)) config.disabledTunings.push(k);
|
||||
});
|
||||
}
|
||||
save();
|
||||
render();
|
||||
};
|
||||
|
||||
header.appendChild(left);
|
||||
header.appendChild(groupToggle);
|
||||
groupWrapper.appendChild(header);
|
||||
|
||||
const groupContainer = document.createElement('div');
|
||||
groupContainer.className = 'border-x border-b border-gray-800/50 rounded-b-lg overflow-hidden';
|
||||
|
||||
const isExpanded = expandedGroups.includes(groupName);
|
||||
if (!isExpanded) {
|
||||
groupContainer.classList.add('hidden');
|
||||
chevron.classList.remove('rotate-90');
|
||||
} else {
|
||||
chevron.classList.add('rotate-90');
|
||||
}
|
||||
|
||||
header.onclick = () => {
|
||||
const idx = expandedGroups.indexOf(groupName);
|
||||
if (idx === -1) {
|
||||
expandedGroups.push(groupName);
|
||||
} else {
|
||||
expandedGroups.splice(idx, 1);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
groupTunings.forEach((name, idx) => {
|
||||
const compoundKey = instrument + ':' + name;
|
||||
const div = document.createElement('div');
|
||||
div.className = `flex items-center justify-between p-2 bg-dark-800/30 hover:bg-dark-800/50 transition-colors ${idx === 0 ? 'border-t-0' : 'border-t border-gray-800/20'}`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'text-xs text-gray-300';
|
||||
label.textContent = name;
|
||||
|
||||
const toggle = document.createElement('input');
|
||||
toggle.type = 'checkbox';
|
||||
toggle.checked = !config.disabledTunings.includes(compoundKey);
|
||||
toggle.className = 'accent-accent';
|
||||
toggle.onchange = () => {
|
||||
if (toggle.checked) {
|
||||
config.disabledTunings = config.disabledTunings.filter(k => k !== compoundKey);
|
||||
} else {
|
||||
if (!config.disabledTunings.includes(compoundKey)) config.disabledTunings.push(compoundKey);
|
||||
}
|
||||
save();
|
||||
render();
|
||||
};
|
||||
|
||||
div.appendChild(label);
|
||||
div.appendChild(toggle);
|
||||
groupContainer.appendChild(div);
|
||||
});
|
||||
groupWrapper.appendChild(groupContainer);
|
||||
visList.appendChild(groupWrapper);
|
||||
});
|
||||
|
||||
const customList = document.getElementById('tuner-custom-list');
|
||||
customList.innerHTML = '';
|
||||
const customNames = Object.keys(config.customTunings);
|
||||
|
||||
+158
-5
@@ -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,13 +428,20 @@ 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() {
|
||||
const btn = document.getElementById('tuner-toggle-btn');
|
||||
if (!btn) return;
|
||||
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
|
||||
if (!state.showFloatingButton || isPlayer || window.feedBack?.isPlaying) {
|
||||
if (isPlayer || window.feedBack?.isPlaying) {
|
||||
btn.classList.add('hidden');
|
||||
} else {
|
||||
btn.classList.remove('hidden');
|
||||
@@ -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,8 +683,77 @@ 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());
|
||||
|
||||
// Re-anchor while the panel is open: the popover it hugs is
|
||||
// vertically centered, so its rect shifts with viewport height.
|
||||
// initUI() runs once (guarded above), so this binds a single listener.
|
||||
window.addEventListener('resize', () => {
|
||||
if (state.uiContainer && !state.uiContainer.classList.contains('hidden')) positionPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the sidebar Plugins popover to anchor the panel beside it.
|
||||
// Prefer the host's stable plugin-control slot API and derive its popover
|
||||
// container; fall back to the known popover id only if that's unavailable.
|
||||
// Returns a visible rect, or null (→ caller uses the fixed fallback slot).
|
||||
function _pluginsPopoverRect() {
|
||||
let pop = null;
|
||||
try {
|
||||
if (window.feedBack?.ui && typeof window.feedBack.ui.playerControlSlot === 'function') {
|
||||
const slot = window.feedBack.ui.playerControlSlot();
|
||||
if (slot instanceof Element) pop = slot.closest('.v3-rail-pop') || slot;
|
||||
}
|
||||
} catch (_e) { /* host slot API failure → fall back to id lookup */ }
|
||||
if (!pop) pop = document.getElementById('v3-rail-pop-plugins');
|
||||
// offsetParent === null covers display:none on the element or any
|
||||
// ancestor (more robust than testing a specific `hidden` class).
|
||||
if (!pop || pop.offsetParent === null) return null;
|
||||
const rect = pop.getBoundingClientRect();
|
||||
return (rect.width || rect.height) ? rect : null;
|
||||
}
|
||||
|
||||
function positionPanel() {
|
||||
@@ -645,7 +773,20 @@ window._tunerUI = function(state, actions) {
|
||||
.replace('right-0', '')
|
||||
.replace('top-full', '')
|
||||
.trim();
|
||||
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
|
||||
const anchor = _pluginsPopoverRect();
|
||||
if (anchor) {
|
||||
// Anchor to the right of the Plugins popover, clamped to the
|
||||
// viewport so the panel never opens off-screen (right/bottom)
|
||||
// on a narrow or short window.
|
||||
const GAP = 8, MARGIN = 8;
|
||||
const pw = state.uiContainer.offsetWidth || 288; // w-72
|
||||
const ph = state.uiContainer.offsetHeight || 0;
|
||||
const left = Math.max(MARGIN, Math.min(anchor.right + GAP, window.innerWidth - pw - MARGIN));
|
||||
const top = Math.max(MARGIN, Math.min(anchor.top, window.innerHeight - ph - MARGIN));
|
||||
state.uiContainer.style.cssText = `top:${top}px;left:${left}px`;
|
||||
} else {
|
||||
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -683,7 +824,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();
|
||||
|
||||
@@ -724,8 +869,15 @@ window._tunerUI = function(state, actions) {
|
||||
btn.textContent = 'Tuner';
|
||||
btn.title = 'Open Tuner';
|
||||
btn.onclick = window.tuner.toggle;
|
||||
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
|
||||
if (closeBtn) controls.insertBefore(btn, closeBtn);
|
||||
// Anchor to the last DIRECT-child button of `controls` (the classic
|
||||
// transport's close/exit button). A bare `button:last-child` can match
|
||||
// a NESTED button that is not a direct child of `controls`, and
|
||||
// `insertBefore()` then throws NotFoundError — which propagated out of
|
||||
// the player-screen transition and aborted its render (feedBack#800).
|
||||
// `:scope > button:last-of-type` restricts the anchor to a direct child;
|
||||
// the parentNode check is a belt-and-suspenders guard before insertBefore.
|
||||
const closeBtn = isV3 ? null : controls.querySelector(':scope > button:last-of-type');
|
||||
if (closeBtn && closeBtn.parentNode === controls) controls.insertBefore(btn, closeBtn);
|
||||
else controls.appendChild(btn);
|
||||
updatePlayerButton();
|
||||
}
|
||||
@@ -737,6 +889,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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Perf-baseline harness for the module-migration refactor (R0).
|
||||
//
|
||||
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
|
||||
// screen-entry, frame-time, memory, or server latency. It writes a markdown
|
||||
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
// with charts (playback frame-time, screen-entry into a live highway) are
|
||||
// clearly labelled — run those against an environment with real songs.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
};
|
||||
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
|
||||
|
||||
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
|
||||
async function serverLatency(paths) {
|
||||
const rows = [];
|
||||
for (const path of paths) {
|
||||
const t = [];
|
||||
let status = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(BASE + path);
|
||||
status = r.status;
|
||||
await r.arrayBuffer();
|
||||
} catch { status = -1; }
|
||||
t.push(performance.now() - t0);
|
||||
}
|
||||
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
|
||||
async function clientMetrics() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const t0 = Date.now();
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
const bootMs = Date.now() - t0;
|
||||
|
||||
// performance.memory is Chromium-only; JS heap after settle.
|
||||
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
await page.waitForTimeout(SOAK_S * 1000);
|
||||
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
|
||||
const scripts = await page.evaluate(() =>
|
||||
document.querySelectorAll('script[data-plugin-id]').length);
|
||||
|
||||
await browser.close();
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
'/api/library?limit=60',
|
||||
'/api/library/artists',
|
||||
]);
|
||||
const client = await clientMetrics();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
|
||||
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
|
||||
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
|
||||
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
|
||||
out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
|
||||
|
||||
console.log(out);
|
||||
+445
-42
@@ -1110,6 +1110,7 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
|
||||
const _LIB_SORT_VALUES = new Set([
|
||||
'artist', 'artist-desc', 'title', 'title-desc',
|
||||
'recent', 'year-desc', 'year', 'tuning',
|
||||
'difficulty', 'difficulty-desc',
|
||||
]);
|
||||
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
|
||||
// Tree-view expand/collapse persistence. Three states per tree:
|
||||
@@ -2078,6 +2079,7 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
</div>
|
||||
${retuneBtn}
|
||||
@@ -2277,6 +2279,8 @@ async function renderTreeInto(containerId, countId, stats, letter, q, favoritesO
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
html += `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>`;
|
||||
if (duration)
|
||||
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
||||
if (stdRetune)
|
||||
@@ -2758,6 +2762,12 @@ function goFavTreePage(p) {
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
let _defaultArrangement = '';
|
||||
|
||||
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
||||
|
||||
function _normalizeInstrumentPathway(value) {
|
||||
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
||||
}
|
||||
|
||||
function _syncDefaultArrangementSelect(value) {
|
||||
const sel = document.getElementById('default-arrangement');
|
||||
if (!sel) return;
|
||||
@@ -2864,6 +2874,10 @@ const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
@@ -2900,6 +2914,10 @@ const HWC_PRESETS = [
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
@@ -3410,6 +3428,8 @@ async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
||||
const leftyEl = document.getElementById('setting-lefty');
|
||||
@@ -3901,6 +3921,18 @@ function persistSetting(key, value) {
|
||||
_settingSaveChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function setInstrumentPathway(value) {
|
||||
const pathway = _normalizeInstrumentPathway(value);
|
||||
const el = document.getElementById('setting-instrument-pathway');
|
||||
if (el) el.value = pathway;
|
||||
persistSetting('pathway', pathway).then(() => {
|
||||
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
||||
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _postSetting(key, value) {
|
||||
const status = document.getElementById('settings-status');
|
||||
try {
|
||||
@@ -4351,7 +4383,7 @@ async function uploadSongs(fileList) {
|
||||
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
|
||||
files.push(f);
|
||||
} else {
|
||||
failures.push(`${f.name}: only .feedpak accepted`);
|
||||
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
@@ -5536,9 +5568,24 @@ function _playbackApi() {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Bridge hits are a "this legacy surface is still in use" signal, not a call
|
||||
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
|
||||
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
|
||||
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
|
||||
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
|
||||
// snapshot serialization on the main thread and saturated the inspector's
|
||||
// hitCount. Throttle per surface: the first call records immediately, repeats
|
||||
// within the window are dropped.
|
||||
const _bridgeRecordLast = new Map();
|
||||
const _BRIDGE_RECORD_MIN_MS = 5000;
|
||||
function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
|
||||
const playback = _playbackApi();
|
||||
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
|
||||
const key = `${bridgeId}|${legacySurface}`;
|
||||
const now = Date.now();
|
||||
const last = _bridgeRecordLast.get(key);
|
||||
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
|
||||
_bridgeRecordLast.set(key, now);
|
||||
playback.recordBridgeHit({
|
||||
bridgeId,
|
||||
legacySurface,
|
||||
@@ -5990,17 +6037,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 ────────────────────────────────────────────────────
|
||||
@@ -6108,7 +6242,7 @@ window.feedBack.on('song:ready', () => {
|
||||
setSpeed(pend.speed);
|
||||
}
|
||||
} catch (_) { /* speed restore is best-effort */ }
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
@@ -6294,9 +6428,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 +6537,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 +6775,92 @@ 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) ? opts.arrangements.slice() : null;
|
||||
if (opts && opts.shuffle && list.length > 1) {
|
||||
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
|
||||
// album slot's pinned arrangement stays glued to its file (#685).
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
|
||||
}
|
||||
}
|
||||
if (window.fbNotify) {
|
||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||
}
|
||||
_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; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
// screen's "Up next: … starting in 10s" strip) without reaching into
|
||||
// queue internals. Null when nothing follows.
|
||||
peekNext: function () {
|
||||
return hasNext()
|
||||
? { filename: list[idx + 1], index: idx + 1, total: list.length }
|
||||
: null;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// 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
|
||||
@@ -7731,6 +7959,10 @@ function setLoopEnd() {
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
|
||||
// transport event so event-driven consumers (note_detect drill sync) see
|
||||
// button-armed loops without having to poll getLoop().
|
||||
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||
}
|
||||
|
||||
function clearLoop(options) {
|
||||
@@ -7891,6 +8123,26 @@ function _resolveEditRegion() {
|
||||
return { a: Math.max(0, t - 4), b: t + 4 };
|
||||
}
|
||||
|
||||
/* @pure:editor-pending-view:start */
|
||||
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
|
||||
const options = opts || {};
|
||||
const view = {
|
||||
filename,
|
||||
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
|
||||
barSel: region ? { startTime: region.a, endTime: region.b } : null,
|
||||
};
|
||||
if (options.returnToHighway) view.returnToHighway = true;
|
||||
if (typeof options.cursorTime === 'number') {
|
||||
view.cursorTime = options.cursorTime;
|
||||
} else if (region && typeof region.a === 'number') {
|
||||
view.cursorTime = region.a;
|
||||
}
|
||||
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
|
||||
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
|
||||
return view;
|
||||
}
|
||||
/* @pure:editor-pending-view:end */
|
||||
|
||||
// Enable "Edit region" whenever the editor plugin is present and a song is
|
||||
// loaded; show "↩ Editor" only while a return context is pending.
|
||||
function _updateEditRegionBtn() {
|
||||
@@ -7917,12 +8169,9 @@ function editRegionInEditor() {
|
||||
arrangement = si.arrangement_index;
|
||||
}
|
||||
} catch (_) { /* default to 0 */ }
|
||||
window._editorPendingView = {
|
||||
filename: currentFilename,
|
||||
arrangement,
|
||||
barSel: { startTime: region.a, endTime: region.b },
|
||||
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
|
||||
returnToHighway: true,
|
||||
};
|
||||
});
|
||||
window.editSong(currentFilename);
|
||||
}
|
||||
window.editRegionInEditor = editRegionInEditor;
|
||||
@@ -7934,14 +8183,14 @@ function returnToEditorFromHighway() {
|
||||
const ctx = window._highwayReturnCtx;
|
||||
if (!ctx || typeof window.editSong !== 'function') return;
|
||||
window._highwayReturnCtx = null;
|
||||
window._editorPendingView = {
|
||||
filename: ctx.filename,
|
||||
arrangement: ctx.arrangement,
|
||||
const region = ctx.barSel
|
||||
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
|
||||
: null;
|
||||
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
|
||||
scrollX: ctx.scrollX,
|
||||
zoom: ctx.zoom,
|
||||
cursorTime: ctx.cursorTime,
|
||||
barSel: ctx.barSel,
|
||||
};
|
||||
});
|
||||
window.editSong(ctx.filename);
|
||||
}
|
||||
window.returnToEditorFromHighway = returnToEditorFromHighway;
|
||||
@@ -8675,12 +8924,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 +9546,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 +9584,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 +9862,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 +9895,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);
|
||||
}
|
||||
@@ -10670,20 +11045,19 @@ async function loadPlugins() {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
for (const [pluginId, contributions] of _pluginUiContributions) {
|
||||
if (livePluginIds.has(pluginId)) continue;
|
||||
const stalePlugin = { id: pluginId };
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
|
||||
}
|
||||
try {
|
||||
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
|
||||
} catch (e) {
|
||||
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
|
||||
}
|
||||
_pluginUiContributions.delete(pluginId);
|
||||
}
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
@@ -10825,17 +11199,23 @@ async function loadPlugins() {
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins that vanished from /api/plugins or are
|
||||
// no longer ready+styled this round. _injectPluginStyles below only
|
||||
// visits plugins still returned by the API, so an uninstalled or
|
||||
// newly-not-ready plugin would otherwise keep its <link> applying.
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (!styled.has(id)) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
@@ -10848,6 +11228,18 @@ async function loadPlugins() {
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
@@ -10875,7 +11267,10 @@ async function loadPlugins() {
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
if (!pid || !alreadyHydrated.has(pid)) el.remove();
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
@@ -10884,7 +11279,7 @@ async function loadPlugins() {
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!alreadyHydrated.has(pid)) el.remove();
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
@@ -11172,6 +11567,14 @@ async function loadPlugins() {
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Core interface-scale capability — the app-wide "Interface size" preference.
|
||||
//
|
||||
// Owns a single user setting: a multiplier applied to the ROOT font-size, so
|
||||
// the rem-based v3 chrome (menus, buttons, text, spacing) scales together. This
|
||||
// is the DOM lever — it deliberately does NOT touch the gameplay highway canvas
|
||||
// (which is sized in device pixels, not rem), so scaling the UI never changes
|
||||
// playback resolution or FPS.
|
||||
//
|
||||
// It is exposed as a host read/write API on `window.feedBack.scale` so surfaces
|
||||
// that can't inherit `rem` — canvas / WebGL renderers such as the note-highway
|
||||
// HUD or results scorecards — can read the number via `feedBack.scale.get()`
|
||||
// and follow `scale:changed`. Shape mirrors the other host capabilities (a
|
||||
// frozen, versioned object) and the working-tuning read-API: synchronous
|
||||
// `get()`, a `set()` mutator, and a change event that also fires once on load.
|
||||
//
|
||||
// The visual apply ALSO runs pre-paint from a tiny inline <head> script (see
|
||||
// index.html) so there is no flash-of-reflow on load; this module is the
|
||||
// authoritative owner and re-applies idempotently.
|
||||
(function () {
|
||||
'use strict';
|
||||
window.feedBack = window.feedBack || {};
|
||||
if (window.feedBack.scale && window.feedBack.scale.version === 1) return;
|
||||
|
||||
var STORE_KEY = 'v3-interface-scale';
|
||||
var MIN = 0.85, MAX = 1.50, DEFAULT = 1.0;
|
||||
// The named presets rendered by the Settings segmented control. Kept here so
|
||||
// the control and any consumer read the ladder from one source of truth.
|
||||
var PRESETS = [
|
||||
{ step: 'small', value: 0.90 },
|
||||
{ step: 'medium', value: 1.00 },
|
||||
{ step: 'large', value: 1.15 },
|
||||
{ step: 'x-large', value: 1.30 },
|
||||
];
|
||||
|
||||
function clamp(n) {
|
||||
n = Number(n);
|
||||
if (!isFinite(n)) return DEFAULT;
|
||||
return Math.min(MAX, Math.max(MIN, n));
|
||||
}
|
||||
|
||||
function stepFor(value) {
|
||||
for (var i = 0; i < PRESETS.length; i++) {
|
||||
if (Math.abs(PRESETS[i].value - value) < 0.001) return PRESETS[i].step;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORE_KEY);
|
||||
if (raw == null) return DEFAULT;
|
||||
return clamp(parseFloat(raw));
|
||||
} catch (_) { return DEFAULT; }
|
||||
}
|
||||
|
||||
var current = read();
|
||||
|
||||
// Apply to the DOM. The lever is a RELATIVE root font-size (a percentage of
|
||||
// the user-agent base), never a px literal — so a user who raised their
|
||||
// browser/OS base font size is respected, not silently overridden. We also
|
||||
// publish the always-present `--fb-scale` token for canvas consumers and CSS.
|
||||
function apply(value) {
|
||||
var el = document.documentElement;
|
||||
if (!el) return;
|
||||
el.style.setProperty('--fb-scale', String(value));
|
||||
// Medium (1.0) clears the inline override so default rendering is
|
||||
// byte-identical to before this feature existed (zero blast radius).
|
||||
el.style.fontSize = (Math.abs(value - 1) < 0.001) ? '' : (value * 100).toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function persist(value) {
|
||||
try {
|
||||
if (Math.abs(value - DEFAULT) < 0.001) localStorage.removeItem(STORE_KEY);
|
||||
else localStorage.setItem(STORE_KEY, String(value));
|
||||
} catch (_) { /* private mode */ }
|
||||
}
|
||||
|
||||
function announce() {
|
||||
try {
|
||||
if (typeof window.feedBack.emit === 'function') {
|
||||
window.feedBack.emit('scale:changed', { value: current, step: stepFor(current) });
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
// Hydrate on load (idempotent with the pre-paint inline script).
|
||||
apply(current);
|
||||
|
||||
window.feedBack.scale = Object.freeze({
|
||||
version: 1,
|
||||
min: MIN,
|
||||
max: MAX,
|
||||
default: DEFAULT,
|
||||
// Synchronous — valid immediately after this module parses.
|
||||
get: function () { return { value: current, step: stepFor(current) }; },
|
||||
// A copy of the preset ladder, so a UI can render it from one source.
|
||||
presets: function () {
|
||||
return PRESETS.map(function (p) { return { step: p.step, value: p.value }; });
|
||||
},
|
||||
// Set + apply + persist + announce. Pass { persist:false } for a
|
||||
// transient preview (e.g. a live slider drag) that shouldn't be written.
|
||||
set: function (value, opts) {
|
||||
var v = clamp(value);
|
||||
current = v;
|
||||
apply(v);
|
||||
if (!opts || opts.persist !== false) persist(v);
|
||||
announce();
|
||||
return current;
|
||||
},
|
||||
});
|
||||
|
||||
// Announce once after the document parses, so any listener wired during page
|
||||
// load can sync without special-casing (consumers may also just call get()).
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', announce, { once: true });
|
||||
} else {
|
||||
announce();
|
||||
}
|
||||
})();
|
||||
@@ -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.tunings && t.tunings[key]) || 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();
|
||||
})();
|
||||
+200
-102
@@ -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;
|
||||
@@ -1954,18 +2004,32 @@ function createHighway() {
|
||||
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
|
||||
ctx.save();
|
||||
ctx.fillStyle = col;
|
||||
ctx.shadowColor = col;
|
||||
ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow
|
||||
ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0, y0);
|
||||
ctx.lineTo(x0 + sw0, y0);
|
||||
ctx.lineTo(x1 + sw1, y1);
|
||||
ctx.lineTo(x1 - sw1, y1);
|
||||
ctx.fill();
|
||||
// Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with
|
||||
// the blurred DEVICE-pixel area, and a held sustain's trail can
|
||||
// span half the (DPR-scaled) canvas — profiling the "stutters
|
||||
// while playing" report put this per-frame blur pass at the top
|
||||
// exactly while a sustain is held. Three inflated low-alpha
|
||||
// fills of the same quad read as the same soft glow at a flat,
|
||||
// area-independent cost. The shimmer LUT still drives the
|
||||
// per-frame size/brightness flicker (feedBack#254 intent).
|
||||
const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a;
|
||||
const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
const fillTrail = (inflate) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0 - inflate, y0);
|
||||
ctx.lineTo(x0 + sw0 + inflate, y0);
|
||||
ctx.lineTo(x1 + sw1 + inflate, y1);
|
||||
ctx.lineTo(x1 - sw1 - inflate, y1);
|
||||
ctx.fill();
|
||||
};
|
||||
ctx.globalAlpha = baseA * 0.22;
|
||||
fillTrail(glowPx);
|
||||
ctx.globalAlpha = baseA * 0.4;
|
||||
fillTrail(glowPx * 0.45);
|
||||
ctx.globalAlpha = baseA;
|
||||
fillTrail(0);
|
||||
// Crackling "current" — a jittery white core line down
|
||||
// the trail, re-randomised each frame.
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31));
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
@@ -2624,6 +2688,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 +3033,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 +3075,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 +3611,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 +3852,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 +3864,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; },
|
||||
|
||||
+4
-1
@@ -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>
|
||||
@@ -71,7 +72,7 @@
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
|
||||
<div id="home" class="screen active">
|
||||
@@ -118,6 +119,8 @@
|
||||
<option value="year-desc">Year (newest)</option>
|
||||
<option value="year">Year (oldest)</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
<option value="difficulty">Difficulty (easiest first)</option>
|
||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
||||
</select>
|
||||
<!-- Format filter (shared) -->
|
||||
<select id="lib-format" onchange="sortLibrary()"
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -71,7 +71,7 @@
|
||||
'<div class="flex items-center gap-2 text-xs text-fb-textDim mb-3">' +
|
||||
'<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13M9 13l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' +
|
||||
'<span>Audio Routing</span></div>' +
|
||||
'<div class="flex items-center gap-2 text-[11px] text-fb-textDim">' +
|
||||
'<div class="flex items-center gap-2 text-[0.6875rem] text-fb-textDim">' +
|
||||
dot(st.inputAvailable) + '<span>Input</span>' +
|
||||
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
|
||||
dot(st.effectActive) + '<span>VST/NAM/IR</span>' +
|
||||
|
||||
+228
-12
@@ -21,10 +21,23 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
|
||||
const PATHWAY_OPTIONS = [
|
||||
{ id: 'songs', label: 'Songs' },
|
||||
{ id: 'practice', label: 'Practice' },
|
||||
{ id: 'learn', label: 'Learn' },
|
||||
{ id: 'studio', label: 'Studio' },
|
||||
];
|
||||
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
|
||||
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
|
||||
let _tuningsByKey = {};
|
||||
// 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,7 +66,53 @@
|
||||
return typeof settings.tuning === 'string' ? settings.tuning : 'Custom';
|
||||
}
|
||||
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
// 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, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
|
||||
|
||||
async function loadTunings() {
|
||||
try {
|
||||
@@ -73,6 +132,15 @@
|
||||
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
|
||||
}
|
||||
|
||||
function pathwayForProfile(profiles, profileId, fallback) {
|
||||
const p = profiles && profiles[profileId];
|
||||
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
|
||||
}
|
||||
|
||||
function profileIdForInstrument(inst) {
|
||||
return inst === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
@@ -97,16 +165,34 @@
|
||||
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
|
||||
else if (Array.isArray(s.tuning)) tuning = s.tuning;
|
||||
else tuning = tunings[0] || 'Standard';
|
||||
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
|
||||
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
|
||||
settings = {
|
||||
instrument: instrument,
|
||||
string_count: scValid,
|
||||
tuning: tuning,
|
||||
reference_pitch: Math.min(450, Math.max(430, ref)),
|
||||
pathway: pathway,
|
||||
instrument_profiles: profiles,
|
||||
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
|
||||
};
|
||||
}
|
||||
} catch (e) { /* settings endpoint always present */ }
|
||||
}
|
||||
|
||||
function syncLocalProfilePatch(patch) {
|
||||
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
|
||||
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
|
||||
if (patch.instrument) settings.active_instrument_profile = profileId;
|
||||
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
|
||||
let changed = false;
|
||||
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
|
||||
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
|
||||
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
|
||||
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
|
||||
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
|
||||
if (changed) settings.instrument_profiles[profileId] = profile;
|
||||
}
|
||||
async function saveSettings(patch) {
|
||||
// Only adopt the patch once the server accepts it. /api/settings returns
|
||||
// {error: ...} with HTTP 200 on a validation failure, so a rejected
|
||||
@@ -122,13 +208,15 @@
|
||||
accepted = !(body && body.error);
|
||||
}
|
||||
} catch (e) { /* non-fatal — leave settings unchanged */ }
|
||||
if (!accepted) return;
|
||||
if (!accepted) return false;
|
||||
Object.assign(settings, patch);
|
||||
syncLocalProfilePatch(patch);
|
||||
if (sm && sm.emit) sm.emit('instrument:changed', {
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
|
||||
});
|
||||
pushToTuner();
|
||||
renderTuner(); // reflect new tuning on the tuner card
|
||||
return true;
|
||||
}
|
||||
|
||||
// Drive the tuner plugin's instrument + tuning from the selection.
|
||||
@@ -242,7 +330,7 @@
|
||||
'<div class="shrink-0 flex flex-col gap-[3px] items-center justify-center">' + meter + '</div>' +
|
||||
'<div class="min-w-0 text-white text-center leading-none">' +
|
||||
'<div data-tuner-note class="text-2xl font-black italic tracking-tighter leading-none">' + esc(initNote) + '</div>' +
|
||||
'<div data-tuner-hz class="text-[9px] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
|
||||
'<div data-tuner-hz class="text-[0.5625rem] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
|
||||
'</div></button>' +
|
||||
'</div>';
|
||||
host.querySelector('[data-open-tuner]').addEventListener('click', (e) => {
|
||||
@@ -257,6 +345,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,26 +424,50 @@
|
||||
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-[0.5625rem] leading-none font-semibold max-w-full truncate px-0.5 ' +
|
||||
(wt.isHome ? 'text-fb-textDim' : 'text-amber-400') + '">' + esc(wt.short) + '</span>' : '') +
|
||||
'<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>' +
|
||||
'</button>' +
|
||||
'<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-[0.6875rem] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
|
||||
'</div>'
|
||||
: '') +
|
||||
instRow('Instrument', ['guitar', 'bass'].map((v) =>
|
||||
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
|
||||
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
|
||||
pill('strings', v, v + '', settings.string_count === v)).join('')) +
|
||||
'<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
// Handedness — a left-hander flips the whole highway (frets mirror).
|
||||
// Lives with the other player-orientation choices so it's part of the
|
||||
// same "Choose your instrument" step the onboarding tour spotlights —
|
||||
// i.e. set before you ever tune up or calibrate.
|
||||
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
|
||||
pill('hand', 'left', 'Left', _leftyPref())) +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
// An offset-array tuning has no named option — surface it as a
|
||||
// disabled, selected 'Custom' entry so the dropdown reflects reality
|
||||
// (picking a named tuning still works and replaces the custom one).
|
||||
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
|
||||
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[10px] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
|
||||
'<select data-inst-pathway class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
|
||||
'</div></div>';
|
||||
|
||||
@@ -335,23 +493,66 @@
|
||||
// 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),
|
||||
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
|
||||
});
|
||||
// Only move the working-tuning context once the switch was actually persisted —
|
||||
// otherwise the selector stays on the old instrument while the card shows the
|
||||
// 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();
|
||||
const newSc = Number(b.getAttribute('data-val'));
|
||||
// Clamp the tuning to one valid for the new string count and post it
|
||||
// alongside string_count — otherwise the backend silently resets a
|
||||
// now-invalid tuning to Standard while this UI keeps showing the old
|
||||
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
|
||||
const tunings = _tuningsForInstrument(settings.instrument, newSc);
|
||||
await saveSettings({
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
});
|
||||
setWorkingInstrument(settings.instrument, newSc);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
|
||||
_setLeftyPref(b.getAttribute('data-val') === 'left');
|
||||
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
|
||||
}));
|
||||
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
|
||||
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
|
||||
const ref = menu.querySelector('[data-inst-ref]');
|
||||
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
|
||||
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
|
||||
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>';
|
||||
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
}
|
||||
// Handedness (left-handed) preference. The canonical store is the highway's
|
||||
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
|
||||
// it immediately. Feature-detected so it works on the dashboard before any
|
||||
// highway has been created (the value is read on the highway's next init).
|
||||
function _leftyPref() {
|
||||
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
|
||||
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
function _setLeftyPref(on) {
|
||||
try {
|
||||
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
|
||||
else localStorage.setItem('lefty', on ? '1' : '0');
|
||||
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
|
||||
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
|
||||
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
|
||||
}
|
||||
function pill(group, val, label, active) {
|
||||
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
|
||||
@@ -362,6 +563,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 +580,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 });
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -23,11 +23,18 @@
|
||||
reg.register({
|
||||
id: 'core.edit-metadata',
|
||||
pluginId: 'core',
|
||||
label: 'Edit metadata',
|
||||
label: 'Details',
|
||||
placement: 'menu',
|
||||
order: 10,
|
||||
applies: (song) => !!(song && song.filename),
|
||||
run: (song) => {
|
||||
// Prefer the v3 Details drawer (identity + personal difficulty / tags
|
||||
// / notes); fall back to the legacy edit-metadata modal where the v3
|
||||
// songs screen hasn't defined the opener (e.g. an older shell).
|
||||
if (typeof window.__fbOpenSongDetails === 'function') {
|
||||
window.__fbOpenSongDetails(song);
|
||||
return;
|
||||
}
|
||||
if (typeof window.openEditModal !== 'function') return;
|
||||
window.openEditModal({
|
||||
f: song.filename, t: song.title || '', a: song.artist || '',
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
const l = fmtName(song);
|
||||
if (!l) return '';
|
||||
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
|
||||
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||
}
|
||||
// Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid
|
||||
// and a corner badge would collide — sits next to the card's label instead.
|
||||
@@ -75,7 +75,7 @@
|
||||
const l = fmtName(song);
|
||||
if (!l) return '';
|
||||
const c = l === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
|
||||
return '<span class="' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
|
||||
return '<span class="' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
|
||||
}
|
||||
|
||||
// ── Continue-Playing resume ──────────────────────────────────────────---
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
|
||||
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
|
||||
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
|
||||
//
|
||||
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
|
||||
// centred panel, light focus trap, Esc closes, overlay click closes) but
|
||||
// layers at z-[200] — the songs.js centered-modal tier — because one of its
|
||||
// openers is the details drawer (z-[61]), which sits above match-review's
|
||||
// z-40/50 pair.
|
||||
//
|
||||
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
|
||||
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
|
||||
// the EXISTING …/art/url route (the override lane: never evicted, survives
|
||||
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
|
||||
// existing …/art/upload (GIF stays upload-only + local-only; the server's
|
||||
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
|
||||
// like the match layer): the modal just closes and the art refreshes.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
// Provenance badge text — same vocabulary as the match layer.
|
||||
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
|
||||
|
||||
let _cur = null; // {filename, title} while the picker is open
|
||||
let _abort = null; // in-flight candidates fetch — cancelled on close
|
||||
let _busy = false; // an apply is running — ignore further tile clicks
|
||||
let _lastFocus = null;
|
||||
|
||||
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
|
||||
|
||||
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
|
||||
// every rendered <img> pointing at this song's art with a fresh v so the
|
||||
// new pick paints everywhere it's currently shown (grid card, drawer
|
||||
// preview, list row) without a full reload.
|
||||
function refreshArt(fn) {
|
||||
const base = artBase(fn);
|
||||
document.querySelectorAll('img').forEach((img) => {
|
||||
const src = img.getAttribute('src') || '';
|
||||
if (src.split('?')[0] === base) {
|
||||
img.src = base + '?v=' + Date.now();
|
||||
img.style.visibility = 'visible';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-imgpick-modal');
|
||||
if (m) return m;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'v3-imgpick-overlay';
|
||||
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
|
||||
overlay.addEventListener('click', close);
|
||||
document.body.appendChild(overlay);
|
||||
m = document.createElement('div');
|
||||
m.id = 'v3-imgpick-modal';
|
||||
// Appended after the overlay: same z tier, DOM order paints it above.
|
||||
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
|
||||
m.innerHTML = '<div id="v3-imgpick-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Change cover"></div>';
|
||||
m.addEventListener('keydown', onKeydown);
|
||||
document.body.appendChild(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
// Light focus trap: cycle within the panel (mirrors match-review).
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
if (!panel) return;
|
||||
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
|
||||
// onerror .hidden, unloadable candidates, .hidden buttons) must never
|
||||
// catch a Tab. offsetParent is null for display:none / .hidden.
|
||||
const foci = Array.from(
|
||||
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
|
||||
).filter((el) => el.offsetParent !== null);
|
||||
if (!foci.length) return;
|
||||
const first = foci[0], last = foci[foci.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
|
||||
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
|
||||
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
|
||||
_cur = null;
|
||||
_busy = false;
|
||||
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
|
||||
_lastFocus = null;
|
||||
}
|
||||
|
||||
// One tile: a 6rem square art/icon face + a caption underneath.
|
||||
function tileHtml(attrs, face, label, hidden) {
|
||||
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
|
||||
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
|
||||
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
|
||||
}
|
||||
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
|
||||
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
|
||||
|
||||
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
|
||||
|
||||
function render(panel) {
|
||||
const fn = _cur.filename;
|
||||
// Fresh ?v so a reopened picker never shows a stale "current".
|
||||
const curSrc = artBase(fn) + '?v=' + Date.now();
|
||||
panel.innerHTML =
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||||
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
|
||||
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||||
|
||||
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
|
||||
// Left: the current cover + its provenance.
|
||||
'<div class="shrink-0">' +
|
||||
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
|
||||
'<div class="pt-1 flex items-center gap-1.5">' +
|
||||
'<span class="text-xs text-fb-textDim">Current</span>' +
|
||||
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
|
||||
'</div></div>' +
|
||||
// Right: the candidate tiles. First row acts instantly; CAA
|
||||
// candidates land behind the one /art/candidates fetch.
|
||||
'<div class="min-w-0 flex-1 space-y-3">' +
|
||||
'<div class="flex flex-wrap gap-3">' +
|
||||
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
|
||||
// Pack tile renders instantly and self-hides when the song ships
|
||||
// no art of its own (?source=pack 404s → img onerror); the
|
||||
// candidates response reconciles it either way.
|
||||
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
|
||||
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
|
||||
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
|
||||
'</div>' +
|
||||
'<div data-ip-caa>' +
|
||||
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
|
||||
'</div>' +
|
||||
// Search Cover Art Archive — find an album cover even when the song has
|
||||
// no match (the auto candidates above are empty then). Pre-filled from
|
||||
// the song's artist + album/title; the source is rate-limited.
|
||||
'<div class="space-y-2 pt-1">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
|
||||
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
|
||||
'</div>' +
|
||||
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
|
||||
'</div>' +
|
||||
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
|
||||
'</div></div>' +
|
||||
'<input type="file" accept="image/*" data-ip-file class="hidden">';
|
||||
|
||||
wire(panel);
|
||||
}
|
||||
|
||||
function wire(panel) {
|
||||
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
|
||||
// The pack tile self-hides when there is no pack art to show.
|
||||
const packTile = panel.querySelector('[data-ip-act="pack"]');
|
||||
const packImg = packTile ? packTile.querySelector('img') : null;
|
||||
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
|
||||
|
||||
const file = panel.querySelector('[data-ip-file]');
|
||||
file?.addEventListener('change', () => {
|
||||
const f = file.files && file.files[0];
|
||||
if (!f) return;
|
||||
const rd = new FileReader();
|
||||
rd.onload = (e) => apply('upload', e.target.result);
|
||||
rd.readAsDataURL(f);
|
||||
});
|
||||
|
||||
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (_busy) return;
|
||||
const act = btn.getAttribute('data-ip-act');
|
||||
if (act === 'keep') { close(); return; }
|
||||
if (act === 'pack') { apply('pack'); return; }
|
||||
if (act === 'upload') { file?.click(); return; }
|
||||
if (act === 'url') {
|
||||
// window.prompt is a silent no-op in Electron — use the
|
||||
// project's injection-safe async modal; fall back to prompt
|
||||
// only if it isn't loaded (mirrors other v3 callers' guard).
|
||||
const ask = (typeof window.uiPrompt === 'function')
|
||||
? window.uiPrompt({
|
||||
title: 'Paste URL',
|
||||
label: 'Paste an image link (http or https)',
|
||||
okLabel: 'Set cover',
|
||||
placeholder: 'https://…',
|
||||
})
|
||||
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
|
||||
const u = String((await ask) || '').trim();
|
||||
if (u) apply('url', u);
|
||||
}
|
||||
});
|
||||
});
|
||||
const searchInput = panel.querySelector('[data-ip-search-input]');
|
||||
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
|
||||
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
|
||||
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
|
||||
panel.querySelector('[data-ip-close]')?.focus();
|
||||
}
|
||||
|
||||
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
|
||||
// render the album covers as pickable tiles — the same apply('url') path as
|
||||
// the auto candidates. Covers with no CAA art self-hide (img onerror).
|
||||
async function coverSearch(panel, query) {
|
||||
const out = panel.querySelector('[data-ip-search-results]');
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!out || !fn) return;
|
||||
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the empty state */ }
|
||||
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
|
||||
const covers = (body && body.covers) || [];
|
||||
if (!covers.length) {
|
||||
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
|
||||
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = covers.map((c, i) =>
|
||||
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
|
||||
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The one candidates fetch, cancelled if the modal closes first. Failure
|
||||
// (offline, demo mode, aborted) is silent: the skeletons just clear and
|
||||
// the instant tiles remain — never an error wall.
|
||||
function loadCandidates(panel) {
|
||||
const fn = _cur.filename;
|
||||
// Reopening without an intervening close() can leave a prior fetch in
|
||||
// flight — cancel it so only the newest request settles the tiles.
|
||||
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
|
||||
_abort = new AbortController();
|
||||
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
|
||||
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
|
||||
}
|
||||
|
||||
function patchCandidates(panel, body) {
|
||||
const wrap = panel.querySelector('[data-ip-caa]');
|
||||
if (!wrap) return;
|
||||
const list = (body && body.candidates) || [];
|
||||
// Reconcile the instant tiles with what the server actually knows.
|
||||
const cur = list.find((c) => c.kind === 'current');
|
||||
const badge = panel.querySelector('[data-ip-prov]');
|
||||
if (badge && cur && PROV_LABEL[cur.provenance]) {
|
||||
badge.textContent = PROV_LABEL[cur.provenance];
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
const packTile = panel.querySelector('[data-ip-act="pack"]');
|
||||
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
|
||||
|
||||
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
|
||||
if (!caa.length) { wrap.innerHTML = ''; return; }
|
||||
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
|
||||
'<div class="flex flex-wrap gap-3">' +
|
||||
caa.map((c, i) => tileHtml(
|
||||
'data-ip-cand="' + i + '"',
|
||||
imgFace(c.thumb_url),
|
||||
c.label || 'Cover')).join('') +
|
||||
'</div>';
|
||||
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
|
||||
// A candidate whose thumb can't load isn't offerable — hide it
|
||||
// rather than let a click apply an image nobody saw.
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden');
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply a pick through the EXISTING routes; silent on success (close +
|
||||
// cache-busted refresh), inline note on failure (the modal stays open so
|
||||
// another tile can be tried).
|
||||
async function apply(kind, arg) {
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!fn || _busy) return;
|
||||
_busy = true;
|
||||
let ok = false;
|
||||
try {
|
||||
let r = null;
|
||||
if (kind === 'url') {
|
||||
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: arg }),
|
||||
});
|
||||
} else if (kind === 'upload') {
|
||||
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: arg }),
|
||||
});
|
||||
} else if (kind === 'pack') {
|
||||
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
|
||||
}
|
||||
if (r && r.ok) {
|
||||
// The art routes report soft failures as {error} bodies.
|
||||
const body = await r.json().catch(() => ({}));
|
||||
ok = !body.error;
|
||||
}
|
||||
} catch (_) { ok = false; }
|
||||
_busy = false;
|
||||
if (ok) { close(); refreshArt(fn); return; }
|
||||
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
|
||||
if (status) {
|
||||
status.textContent = 'Couldn’t set that cover — try another image.';
|
||||
status.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function openImagePicker(opts) {
|
||||
const filename = opts && opts.filename;
|
||||
if (!filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
const title = (opts && opts.title) || filename;
|
||||
const artist = (opts && opts.artist) || '';
|
||||
const album = (opts && opts.album) || '';
|
||||
// Pre-fill the cover search: "artist album" when the album is known, else
|
||||
// just the artist, else the title — the server default backs it up.
|
||||
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
|
||||
_cur = { filename: filename, title: title, query: query };
|
||||
_busy = false;
|
||||
const m = ensureModal();
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
render(panel);
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
|
||||
loadCandidates(panel);
|
||||
}
|
||||
|
||||
window.__fbOpenImagePicker = openImagePicker;
|
||||
})();
|
||||
+183
-7
@@ -60,6 +60,24 @@
|
||||
} catch (_) { /* file:// or sandboxed iframe */ }
|
||||
})();
|
||||
</script>
|
||||
<!-- Interface size (Accessibility): apply the saved UI scale to the root
|
||||
font-size BEFORE any stylesheet paints, so there is no flash-of-reflow
|
||||
on load. Mirrors capabilities/interface-scale.js, which is the
|
||||
authoritative owner and re-applies once it parses. Relative % (never a
|
||||
px literal) so a raised browser/OS base font size is respected. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('v3-interface-scale');
|
||||
if (raw == null) return;
|
||||
var v = Math.min(1.5, Math.max(0.85, parseFloat(raw)));
|
||||
if (!isFinite(v) || Math.abs(v - 1) < 0.001) return;
|
||||
var el = document.documentElement;
|
||||
el.style.setProperty('--fb-scale', String(v));
|
||||
el.style.fontSize = (v * 100).toFixed(2) + '%';
|
||||
} catch (_) { /* file:// or private mode */ }
|
||||
})();
|
||||
</script>
|
||||
<!-- fee[dB]ack mark (the [dB] motif). SVG primary, PNG fallback. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/static/v3/brand/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/v3/brand/favicon-32.png">
|
||||
@@ -87,6 +105,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>
|
||||
@@ -95,6 +114,7 @@
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
<script src="/static/capabilities/interface-scale.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -102,7 +122,7 @@
|
||||
the inline brand is the no-JS fallback. -->
|
||||
<aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex">
|
||||
<div id="v3-brand" class="p-6">
|
||||
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
|
||||
<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
|
||||
</div>
|
||||
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
|
||||
</aside>
|
||||
@@ -174,7 +194,7 @@
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
|
||||
<div id="home" class="screen">
|
||||
@@ -343,7 +363,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>
|
||||
@@ -355,6 +378,7 @@
|
||||
panels (manifest settings.category routes the others). -->
|
||||
<div class="fb-tabbar" id="settings-tabbar">
|
||||
<button type="button" class="fb-tab" data-tab="gameplay">Gameplay</button>
|
||||
<button type="button" class="fb-tab" data-tab="accessibility">Accessibility</button>
|
||||
<button type="button" class="fb-tab" data-tab="audio">Audio</button>
|
||||
<button type="button" class="fb-tab" data-tab="graphics">Graphics</button>
|
||||
<button type="button" class="fb-tab" data-tab="keybinds">Keybinds</button>
|
||||
@@ -405,6 +429,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Instrument pathway -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Instrument pathway</div>
|
||||
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="songs">Songs</option>
|
||||
<option value="practice">Practice</option>
|
||||
<option value="learn">Learn</option>
|
||||
<option value="studio">Studio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Arrangement routes (naming mode) -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
@@ -536,6 +577,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ ACCESSIBILITY ═══════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="accessibility">
|
||||
<div class="fb-tabpanel-head"><h3>Accessibility</h3></div>
|
||||
<div class="fb-srows">
|
||||
<!-- Interface size -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7V5a1 1 0 011-1h14a1 1 0 011 1v2M9 20h6M12 4v16"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Interface size</div>
|
||||
<div class="fb-srow-desc">Make the app’s menus, buttons and text larger or smaller. This changes the <strong>app interface</strong> — not the notes on the note highway (set those under <strong>Graphics</strong>). On desktop you can also press <strong>Ctrl +</strong> / <strong>Ctrl −</strong> to zoom the whole window.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-seg" id="setting-interface-size" role="group" aria-label="Interface size">
|
||||
<button type="button" class="fb-seg-btn" data-scale="0.90" aria-pressed="false" onclick="window.feedBack.scale.set(0.90)">Small</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.00" aria-pressed="false" onclick="window.feedBack.scale.set(1.00)">Medium</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.15" aria-pressed="false" onclick="window.feedBack.scale.set(1.15)">Large</button>
|
||||
<button type="button" class="fb-seg-btn" data-scale="1.30" aria-pressed="false" onclick="window.feedBack.scale.set(1.30)">Extra Large</button>
|
||||
</div>
|
||||
<p class="fb-srow-desc" style="margin-top:.55rem;">Larger sizes are recommended for large or high-resolution displays.</p>
|
||||
<details class="fb-finetune">
|
||||
<summary>Fine-tune size</summary>
|
||||
<div class="fb-finetune-body">
|
||||
<input type="range" id="setting-interface-size-slider" min="85" max="150" step="5" value="100"
|
||||
oninput="window.feedBack.scale.set(this.value / 100, { persist: false })"
|
||||
onchange="window.feedBack.scale.set(this.value / 100)" class="fb-srow-wide slider-input"
|
||||
aria-label="Interface size percent">
|
||||
<span class="fb-seg-val"><span id="setting-interface-size-val">100</span>%</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ AUDIO ═══════════════════════════════════════════════════ -->
|
||||
<div class="fb-tabpanel" data-tab="audio">
|
||||
<div class="fb-tabpanel-head"><h3>Audio Settings</h3></div>
|
||||
@@ -566,7 +641,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<!-- Autosave on blur/enter via a single-key POST (like every other v3
|
||||
setting), so setting the address never depends on the shared Save
|
||||
button — whose bundled dlc_dir could otherwise block it. Save button
|
||||
kept for discoverability. -->
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
onchange="persistSetting('demucs_server_url', this.value.trim())"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
@@ -677,6 +757,76 @@
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata matching (P8 — wired by static/v3/match-review.js) -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Metadata matching</div>
|
||||
<div class="fb-srow-desc">Matches your charts against MusicBrainz in the background to tidy names, years and genres — display only, your files are never modified. Matches at or above the confidence level apply automatically; the rest wait in the library's review queue. Turning this off never disables manual match fixes.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Match songs automatically</label>
|
||||
<label class="flex items-center gap-2">Auto-apply confidence
|
||||
<select id="enrich-threshold" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="0.85">85% — more auto-matches</option>
|
||||
<option value="0.9" selected>90% (recommended)</option>
|
||||
<option value="0.95">95% — cautious</option>
|
||||
<option value="1.01">Always review</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- R1 scraper options: sources (who may be contacted) ×
|
||||
auto-apply fields (what a confident match fills in). -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Sources</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-musicbrainz" checked class="rounded border-gray-600 bg-dark-700 text-accent"> MusicBrainz — names, years, genres</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-caa" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover Art Archive — album covers</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Auto-apply</div>
|
||||
<div class="grid grid-cols-4 gap-2 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-names" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Names</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-year" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Year</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-genres" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Genres</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-art" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover art</label>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
|
||||
</div>
|
||||
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
|
||||
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2">Review queue order
|
||||
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="missing_first" selected>Missing info first</option>
|
||||
<option value="artist">Artist A–Z</option>
|
||||
<option value="recent">Recently added</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<button id="enrich-match-now" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Match Now</button>
|
||||
<span id="enrich-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Artist pages (PR-B — wired by static/v3/match-review.js). Sits
|
||||
beside the Metadata matching card; the Settings→Library tab
|
||||
regroup is a separate PR. -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Artist pages</div>
|
||||
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
@@ -825,7 +975,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 +1002,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 +1073,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">
|
||||
@@ -1096,14 +1264,22 @@
|
||||
<script src="/static/v3/pedal-cables.js"></script>
|
||||
<script src="/static/v3/plugins-page.js"></script>
|
||||
<script src="/static/v3/card-actions-core.js"></script>
|
||||
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
|
||||
on build, so the module must already be registered. -->
|
||||
<script src="/static/v3/match-review.js"></script>
|
||||
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
|
||||
the cover picker (window.__fbOpenImagePicker). -->
|
||||
<script src="/static/v3/image-picker.js"></script>
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<script src="/static/v3/settings.js"></script>
|
||||
<script src="/static/v3/interface-size-ui.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
<script src="/static/v3/onboarding-tour.js"></script>
|
||||
<script src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script src="/static/v3/feedbarcade.js"></script>
|
||||
<script src="/static/v3/player-chrome.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// v3 first-run "Interface size" nudge.
|
||||
//
|
||||
// The Accessibility → Interface size control is the primary discovery path, but
|
||||
// the exact person who needs it — someone on a large, low-DPI display (e.g. a
|
||||
// 32" 1440p panel with no OS scaling) — is the one least likely to go hunting
|
||||
// for it. So, ONCE, for that specific display profile, surface a gentle,
|
||||
// dismissible toast that deep-links to the control. Never fires if the user has
|
||||
// already touched the setting, on smaller/high-DPI displays, or more than once.
|
||||
//
|
||||
// Plain non-module script; degrades to a no-op without the bus, fbNotify, or DOM.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SEEN_KEY = 'v3-interface-size-nudged';
|
||||
var SCALE_KEY = 'v3-interface-scale';
|
||||
|
||||
function alreadyHandled() {
|
||||
try {
|
||||
return localStorage.getItem(SEEN_KEY) === '1' || localStorage.getItem(SCALE_KEY) != null;
|
||||
} catch (_) { return true; }
|
||||
}
|
||||
|
||||
// The target profile: a physically large viewport rendered near 1:1 (so the
|
||||
// OS isn't already enlarging things). This is the eye-strain case.
|
||||
function isLargeLowDpiDisplay() {
|
||||
var w = window.innerWidth || 0;
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
return w >= 1800 && dpr <= 1.25;
|
||||
}
|
||||
|
||||
// Don't interrupt a first-run modal (e.g. profile onboarding). If one is up,
|
||||
// leave the flag UNSET so the nudge gets another chance on a later launch.
|
||||
function aModalIsOpen() {
|
||||
var dialogs = document.querySelectorAll('[role="dialog"], .fixed.inset-0');
|
||||
for (var i = 0; i < dialogs.length; i++) {
|
||||
var el = dialogs[i];
|
||||
if (el.offsetParent !== null && el.getClientRects().length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function openSetting() {
|
||||
try {
|
||||
if (typeof window.showScreen === 'function') window.showScreen('settings');
|
||||
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
|
||||
if (b.dataset.tab === 'accessibility') b.click();
|
||||
});
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
function maybeNudge() {
|
||||
if (alreadyHandled()) return;
|
||||
if (!isLargeLowDpiDisplay()) return;
|
||||
if (!window.fbNotify || typeof window.fbNotify.show !== 'function') return;
|
||||
if (aModalIsOpen()) return; // try again next launch
|
||||
|
||||
try { localStorage.setItem(SEEN_KEY, '1'); } catch (_) { /* private mode */ }
|
||||
|
||||
var card = window.fbNotify.show({
|
||||
title: 'Text looking small?',
|
||||
message: 'Make the menus and text larger — tap to open Interface size.',
|
||||
icon: '🔍',
|
||||
accent: '#0ea5e9',
|
||||
durationMs: 9000,
|
||||
});
|
||||
if (card && card.addEventListener) card.addEventListener('click', openSetting);
|
||||
}
|
||||
|
||||
function start() {
|
||||
// Let the app settle (boot, any onboarding) before offering the nudge.
|
||||
setTimeout(maybeNudge, 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
// v3 Settings → Accessibility: keeps the "Interface size" control in sync with
|
||||
// the host `feedBack.scale` capability. The buttons/slider WRITE via inline
|
||||
// `feedBack.scale.set(...)`; this module only REFLECTS current state (active
|
||||
// preset, slider position, % readout) so the control mirrors the live value on
|
||||
// load, on every change, and each time Settings is opened.
|
||||
//
|
||||
// Plain non-module script, matching the rest of static/v3/*. Null-guarded so it
|
||||
// no-ops on the classic v2 page (which has no Accessibility panel).
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function sync(state) {
|
||||
if (!state) {
|
||||
var cap = window.feedBack && window.feedBack.scale;
|
||||
state = cap && typeof cap.get === 'function' ? cap.get() : null;
|
||||
}
|
||||
if (!state) return;
|
||||
|
||||
var seg = document.getElementById('setting-interface-size');
|
||||
if (seg) {
|
||||
seg.querySelectorAll('.fb-seg-btn').forEach(function (b) {
|
||||
var on = Math.abs(parseFloat(b.dataset.scale) - state.value) < 0.001;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
var slider = document.getElementById('setting-interface-size-slider');
|
||||
if (slider && document.activeElement !== slider) {
|
||||
slider.value = String(Math.round(state.value * 100));
|
||||
}
|
||||
var val = document.getElementById('setting-interface-size-val');
|
||||
if (val) val.textContent = String(Math.round(state.value * 100));
|
||||
}
|
||||
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
// Fires on every set() and once on load. The bus delivers a CustomEvent,
|
||||
// so we ignore the arg and read the authoritative value via sync() → get().
|
||||
window.feedBack.on('scale:changed', function () { sync(); });
|
||||
// Re-sync when the user opens Settings (the panel may have re-rendered).
|
||||
window.feedBack.on('screen:changed', function (e) {
|
||||
var id = e && (e.detail ? e.detail.id : e.id);
|
||||
if (id === 'settings') sync();
|
||||
});
|
||||
}
|
||||
// Settings markup is static, but re-sync when settings.js signals it wired.
|
||||
document.addEventListener('v3:settings-rendered', function () { sync(); });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
|
||||
} else {
|
||||
sync();
|
||||
}
|
||||
|
||||
window.feedBackInterfaceSize = { sync: sync };
|
||||
})();
|
||||
@@ -56,8 +56,8 @@
|
||||
const shown = arr.slice(0, max || 6);
|
||||
const extra = arr.length - shown.length;
|
||||
let html = shown.map((t) =>
|
||||
'<span class="text-[10px] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
|
||||
if (extra > 0) html += '<span class="text-[10px] text-fb-textDim">+' + extra + '</span>';
|
||||
'<span class="text-[0.625rem] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
|
||||
if (extra > 0) html += '<span class="text-[0.625rem] text-fb-textDim">+' + extra + '</span>';
|
||||
return '<div class="flex flex-wrap gap-1">' + html + '</div>';
|
||||
}
|
||||
function progressBar(passed, total) {
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
// Match-Review UI (P8 — library-metadata design §5/§11). A self-contained
|
||||
// module: the ambient "⚑ N to review" chip lives in the songs toolbar
|
||||
// (songs.js renders the element and calls the hooks below); the review MODAL,
|
||||
// the per-field available/missing detail, and the Settings → Library
|
||||
// "Metadata matching" card behaviour all live here.
|
||||
//
|
||||
// The modal reviews ONE chart at a time (the scraper-review model from
|
||||
// media-server / emulation-frontend apps): the chart's current metadata —
|
||||
// with explicit "Missing: …" chips — above the candidate list, each
|
||||
// candidate carrying "Adds / Shows as" chips, with Skip / Not a match /
|
||||
// Search instead / Use selected plus ‹ › navigation.
|
||||
//
|
||||
// Engagement guardrails (§11): opt-in tool-state, not a score. The chip only
|
||||
// appears when there is something to review, matching is silent on success
|
||||
// (no toasts, no sounds — hearing-safe), and nothing here ever writes to
|
||||
// pack files; a confirmed match only improves the local display cache.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
function artUrl(song) {
|
||||
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
|
||||
return '/api/song/' + enc(song.filename) + '/art' + v;
|
||||
}
|
||||
|
||||
function fmtDur(sec) {
|
||||
if (!sec && sec !== 0) return '';
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ── Ambient chip + the Settings card's status line ───────────────────────
|
||||
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
|
||||
// calls window.__fbMatchReviewChip() after each toolbar build; review
|
||||
// actions here re-call it. The same fetch feeds the Settings status line
|
||||
// and, while a pass is running, a quiet toolbar progress line (below).
|
||||
// Silent on failure — surfaces just stay as they are.
|
||||
let _chipBusy = false;
|
||||
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
|
||||
|
||||
// Quiet library-visible progress (launch polish): a plain text line next
|
||||
// to the review chip while the background pass is working through the
|
||||
// queue — "Matching your library — X of Y". No toast, no sound; it simply
|
||||
// disappears when the pass finishes (hearing-safe, design §11).
|
||||
function _setProgressLine(running, states, total) {
|
||||
let el = document.getElementById('v3-songs-match-progress');
|
||||
const unscanned = states.unscanned || 0;
|
||||
if (!running || unscanned <= 0 || total <= 0) {
|
||||
if (el) el.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
const chip = document.getElementById('v3-songs-match-review');
|
||||
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
|
||||
el = document.createElement('span');
|
||||
el.id = 'v3-songs-match-progress';
|
||||
el.className = 'text-xs text-fb-textDim';
|
||||
chip.insertAdjacentElement('afterend', el);
|
||||
}
|
||||
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
|
||||
}
|
||||
|
||||
// One-time transparency toast (launch polish): the first time this
|
||||
// install is observed actually matching a real library, say plainly what
|
||||
// is contacted, where results live, and where the switch is. Wrapped like
|
||||
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
|
||||
// never break the chip.
|
||||
function _announceOnce(running, total) {
|
||||
try {
|
||||
if (!running || total <= 0) return;
|
||||
if (localStorage.getItem('fb_enrich_announce_v1')) return;
|
||||
localStorage.setItem('fb_enrich_announce_v1', '1');
|
||||
window.fbNotify?.show({
|
||||
title: 'Library matching is on',
|
||||
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
|
||||
icon: '📚',
|
||||
});
|
||||
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
|
||||
}
|
||||
|
||||
async function refreshChip() {
|
||||
if (_chipBusy) return;
|
||||
_chipBusy = true;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/status');
|
||||
if (!r.ok) return;
|
||||
const body = await r.json();
|
||||
const st = body.states || {};
|
||||
const n = st.review || 0;
|
||||
const chip = document.getElementById('v3-songs-match-review');
|
||||
if (chip) {
|
||||
chip.textContent = '⚑ ' + n + ' to review';
|
||||
chip.classList.toggle('hidden', !n);
|
||||
}
|
||||
const line = document.getElementById('enrich-status');
|
||||
if (line) {
|
||||
const parts = [
|
||||
((st.matched || 0) + (st.manual || 0)) + ' matched',
|
||||
n + ' to review',
|
||||
(st.failed || 0) + ' unmatched',
|
||||
];
|
||||
if (st.unscanned) parts.push(st.unscanned + ' queued');
|
||||
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
|
||||
}
|
||||
const running = !!body.running;
|
||||
const total = body.total_songs || 0;
|
||||
_setProgressLine(running, st, total);
|
||||
_announceOnce(running, total);
|
||||
// Poll only while a pass is actually running; a single guarded
|
||||
// interval, cleared the moment the pass stops (no leaks).
|
||||
if (running && !_pollTimer) {
|
||||
_pollTimer = setInterval(refreshChip, 5000);
|
||||
} else if (!running && _pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline — leave surfaces as they are, but stop any poll so a
|
||||
// dead server isn't pinged every 5s forever (the next toolbar
|
||||
// build / settings open restarts it if a pass is still running).
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||
} finally {
|
||||
_chipBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review modal (body-appended singleton, one chart at a time) ─────────
|
||||
let _queue = [];
|
||||
let _idx = 0;
|
||||
let _lastFocus = null;
|
||||
let _single = false; // Fix-metadata mode: one song, no queue navigation
|
||||
let _tab = 'details'; // active tab in single mode: details | cover | match
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-match-modal');
|
||||
if (m) return m;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'v3-match-overlay';
|
||||
overlay.className = 'fixed inset-0 bg-black/60 z-40 hidden';
|
||||
overlay.addEventListener('click', closeModal);
|
||||
document.body.appendChild(overlay);
|
||||
m = document.createElement('div');
|
||||
m.id = 'v3-match-modal';
|
||||
m.className = 'fixed inset-0 z-50 hidden flex items-center justify-center p-4 pointer-events-none';
|
||||
m.innerHTML = '<div id="v3-match-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Match review"></div>';
|
||||
m.addEventListener('keydown', onModalKeydown);
|
||||
document.body.appendChild(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function isTyping(e) {
|
||||
const t = e.target;
|
||||
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA');
|
||||
}
|
||||
|
||||
function onModalKeydown(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); closeModal(); return; }
|
||||
if (e.key === 'ArrowLeft' && !isTyping(e)) { e.preventDefault(); nav(-1); return; }
|
||||
if (e.key === 'ArrowRight' && !isTyping(e)) { e.preventDefault(); nav(1); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
// Light focus trap: cycle within the panel.
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
const foci = panel.querySelectorAll('button, input, [tabindex="0"]');
|
||||
if (!foci.length) return;
|
||||
const first = foci[0], last = foci[foci.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
_lastFocus = document.activeElement;
|
||||
_single = false;
|
||||
const m = ensureModal();
|
||||
renderLoading();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
|
||||
// song, reachable from the card's ⋮ / right-click menu. Three tabs —
|
||||
// Details (type + lock the displayed fields), Cover art (launch the picker),
|
||||
// Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
|
||||
// blank-artist packs this exists to fix, typing the right title is the tool,
|
||||
// and Match is the escape hatch when text search can surface a record.
|
||||
function fixMatch(song) {
|
||||
if (!song || !song.filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_single = true;
|
||||
_tab = 'details';
|
||||
_queue = [{
|
||||
filename: song.filename, title: song.title || song.filename,
|
||||
artist: song.artist || '', album: song.album || '',
|
||||
year: song.year || '', duration: song.duration,
|
||||
mtime: song.mtime, candidates: [],
|
||||
}];
|
||||
_idx = 0;
|
||||
const m = ensureModal();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
renderCurrent(); // _single ⇒ renderTabbed()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('v3-match-modal')?.classList.add('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.add('hidden');
|
||||
_single = false;
|
||||
refreshChip();
|
||||
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } }
|
||||
_lastFocus = null;
|
||||
}
|
||||
|
||||
function nav(step) {
|
||||
if (_single || !_queue.length) return; // single mode has no queue to page
|
||||
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/review?limit=200');
|
||||
_queue = r.ok ? ((await r.json()).songs || []) : [];
|
||||
} catch (_) { _queue = []; }
|
||||
_idx = 0;
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
function headerHtml() {
|
||||
const counter = (_queue.length && !_single)
|
||||
? '<span class="flex items-center gap-1 text-xs text-fb-textDim">' +
|
||||
'<button data-mr-prev class="px-2 py-1 rounded hover:text-fb-text' + (_idx === 0 ? ' opacity-30' : '') + '" aria-label="Previous">‹</button>' +
|
||||
(_idx + 1) + ' of ' + _queue.length +
|
||||
'<button data-mr-next class="px-2 py-1 rounded hover:text-fb-text' + (_idx >= _queue.length - 1 ? ' opacity-30' : '') + '" aria-label="Next">›</button></span>'
|
||||
: '';
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">' + (_single ? 'Fix match' : 'Match review') + '</h3>' + counter +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>';
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-2"><p class="text-sm text-fb-text">Nothing waiting for review.</p>' +
|
||||
'<p class="text-xs text-fb-textDim">Medium-confidence matches queue here while the library is matched in the background. Matching options live in Settings → Library.</p></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
}
|
||||
|
||||
// Amber "what this chart lacks" chips. Album/year come from the library
|
||||
// row; cover art is detected from the art request failing (flagged onto
|
||||
// the song object by the <img> onerror handler, then re-rendered).
|
||||
function missingChips(song) {
|
||||
const missing = [];
|
||||
if (!String(song.album || '').trim()) missing.push('album');
|
||||
if (!String(song.year || '').trim()) missing.push('year');
|
||||
if (song._artMissing) missing.push('cover art');
|
||||
if (!missing.length) return '';
|
||||
return '<div class="flex flex-wrap items-center gap-1 pt-1">' +
|
||||
'<span class="text-xs text-fb-textDim">Missing:</span>' +
|
||||
missing.map((f) => '<span class="text-xs px-1.5 py-0.5 rounded border border-amber-400/40 text-amber-300/90 bg-amber-400/10">' + esc(f) + '</span>').join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Per-candidate "what accepting this gets you": fields the chart lacks
|
||||
// that the candidate supplies, and fields whose DISPLAYED value would
|
||||
// change (never the file).
|
||||
function diffChips(song, cand) {
|
||||
const adds = [];
|
||||
const changes = [];
|
||||
const have = (v) => String(v == null ? '' : v).trim();
|
||||
const differ = (a, b) => have(a) && have(b) && have(a).toLowerCase() !== have(b).toLowerCase();
|
||||
if (have(cand.album)) { if (!have(song.album)) adds.push('album'); else if (differ(song.album, cand.album)) changes.push('album'); }
|
||||
if (have(cand.year)) { if (!have(song.year)) adds.push('year'); else if (differ(song.year, cand.year)) changes.push('year'); }
|
||||
if (cand.genres && cand.genres.length) adds.push('genres');
|
||||
if (have(cand.isrc)) adds.push('ISRC');
|
||||
if (differ(song.artist, cand.artist)) changes.push(have(song.artist) + ' → ' + have(cand.artist));
|
||||
if (differ(song.title, cand.title)) changes.push('title');
|
||||
let html = '';
|
||||
if (adds.length) html += '<span class="text-xs text-fb-good">Adds: ' + esc(adds.join(' · ')) + '</span>';
|
||||
if (changes.length) html += (html ? ' ' : '') + '<span class="text-xs text-fb-textDim">Shows as: ' + esc(changes.join(' · ')) + '</span>';
|
||||
return html ? '<span class="block truncate pt-0.5">' + html + '</span>' : '';
|
||||
}
|
||||
|
||||
function candRowHtml(song, c, i, selected) {
|
||||
const meta = [c.artist, c.album, c.year, fmtDur(c.duration)].filter(Boolean).join(' · ');
|
||||
const pct = c.score != null ? Math.round(c.score * 100) + '%' : '';
|
||||
return '<button data-mr-cand="' + i + '" role="radio" aria-checked="' + (selected ? 'true' : 'false') + '" class="w-full text-left px-3 py-2 rounded-md border ' +
|
||||
(selected ? 'border-fb-primary bg-fb-primary/10' : 'border-fb-border/50 bg-gray-800/50 hover:border-fb-primary/60') + '">' +
|
||||
'<span class="flex items-baseline justify-between gap-2">' +
|
||||
'<span class="text-sm text-fb-text truncate">' + esc(c.title) + '</span>' +
|
||||
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
|
||||
diffChips(song, c) +
|
||||
(_single ? '<span class="block text-xs text-fb-primary pt-1">Use these values →</span>' : '') +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
// The middle content shared by the queue-review render and the single-song
|
||||
// popup's Match tab: the chart being matched, its candidate list, and the
|
||||
// "search instead" panel. Header + footer differ per surface. When there
|
||||
// are no stored candidates (a manual fix), the search panel opens pre-filled
|
||||
// — searching IS the point in that case.
|
||||
function reviewBodyHtml(song) {
|
||||
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
|
||||
const noCands = !(song.candidates || []).length;
|
||||
const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' – ') : '';
|
||||
return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
// The chart being matched
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
'<div class="min-w-0">' +
|
||||
'<div class="text-base text-fb-text font-medium truncate">' + esc(song.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(sub) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
|
||||
missingChips(song) +
|
||||
'</div></div>' +
|
||||
(noCands
|
||||
? ''
|
||||
: '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
|
||||
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
|
||||
'</div>') +
|
||||
// Search panel — hidden when candidates exist (a "Search instead…"
|
||||
// toggle reveals it); open + pre-filled when there are none.
|
||||
'<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-mr-search-input type="text" value="' + esc(prefill) + '" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
|
||||
'<div data-mr-search-results class="space-y-1"></div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Footer actions. Single mode drops Skip / Not-a-match (no queue); the
|
||||
// accept button only shows when there is a stored candidate to accept —
|
||||
// search-result rows carry their own pick action.
|
||||
function footerHtml(song) {
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
|
||||
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</button></div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
|
||||
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (_single) { renderTabbed(); return; } // popup: the tabbed shell
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(song);
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
|
||||
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
|
||||
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
|
||||
wireReviewBody(panel, song);
|
||||
}
|
||||
|
||||
// Candidate / search / accept-reject wiring shared by the queue render and
|
||||
// the popup's Match tab. Scoped to `root` so the tabbed shell can wire just
|
||||
// its tab body — its close + tab chrome live in the header (wired once by
|
||||
// renderTabbed), so wiring here must NOT touch close/prev/next/skip.
|
||||
function wireReviewBody(root, song) {
|
||||
// Art failure → flag + re-render once so the "cover art" chip shows.
|
||||
const img = root.querySelector('[data-mr-art]');
|
||||
if (img) img.onerror = () => {
|
||||
img.style.visibility = 'hidden';
|
||||
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
|
||||
};
|
||||
root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
song._sel = Number(btn.getAttribute('data-mr-cand'));
|
||||
renderCurrent();
|
||||
});
|
||||
});
|
||||
root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
const cand = (song.candidates || [])[song._sel || 0];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
|
||||
{ recording_id: cand.recording_id });
|
||||
settle(song);
|
||||
});
|
||||
root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
|
||||
settle(song);
|
||||
});
|
||||
const sp = root.querySelector('[data-mr-search-panel]');
|
||||
const input = root.querySelector('[data-mr-search-input]');
|
||||
root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
sp?.classList.toggle('hidden');
|
||||
if (sp && !sp.classList.contains('hidden') && input && !input.value) {
|
||||
input.value = [song.artist, song.title].filter(Boolean).join(' – ');
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
const go = () => runSearch(root, song);
|
||||
root.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
||||
// Identify-by-audio (AcoustID, #759) renders its hits into the same
|
||||
// search-results area — scope to `root` (the tab body / panel), not the
|
||||
// out-of-scope `panel` the pre-refactor #759 wiring referenced.
|
||||
root.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(root, song));
|
||||
}
|
||||
|
||||
// ── Tabbed single-song popup (slice 4) ───────────────────────────────────
|
||||
// Header + tab bar, then the active tab's body. The queue-review render
|
||||
// above is untouched; this is only reached in _single mode.
|
||||
function tabHeaderHtml() {
|
||||
const tab = (id, label) =>
|
||||
'<button data-mr-tab="' + id + '" role="tab" aria-selected="' + (_tab === id ? 'true' : 'false') + '" ' +
|
||||
'class="px-3 py-2 text-sm -mb-px border-b-2 ' + (_tab === id
|
||||
? 'border-fb-primary text-fb-text'
|
||||
: 'border-transparent text-fb-textDim hover:text-fb-text') + '">' + label + '</button>';
|
||||
return '<div class="flex items-center justify-between gap-3 px-5 pt-4 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">Fix metadata</h3>' +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||||
'<div role="tablist" class="flex gap-1 px-4 border-b border-fb-border/40 shrink-0">' +
|
||||
tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '</div>';
|
||||
}
|
||||
|
||||
function renderTabbed() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
const song = _queue[0];
|
||||
if (!song) { closeModal(); return; }
|
||||
panel.innerHTML = tabHeaderHtml() +
|
||||
'<div data-mr-tabbody role="tabpanel" class="flex flex-col min-h-0 flex-1 overflow-hidden"></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
|
||||
const t = b.getAttribute('data-mr-tab');
|
||||
if (t !== _tab) { _tab = t; renderTabbed(); }
|
||||
}));
|
||||
const body = panel.querySelector('[data-mr-tabbody]');
|
||||
if (_tab === 'details') { renderDetailsTab(body, song); }
|
||||
else if (_tab === 'cover') { renderCoverTab(body, song); }
|
||||
else {
|
||||
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
|
||||
wireReviewBody(body, song);
|
||||
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
|
||||
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
|
||||
// Each field sits on its pack value: editing above the pack makes it an
|
||||
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
|
||||
// it; revert (↺) drops back to the pack value.
|
||||
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']];
|
||||
// Only these four are written into the pack file; genre is a library-only
|
||||
// overlay (drives the genre filter/facet + the auto-match lock), never baked
|
||||
// to the file — so Write to file leaves genre's override in place.
|
||||
const WRITE_FIELDS = ['title', 'artist', 'album', 'year'];
|
||||
|
||||
async function renderDetailsTab(body, song) {
|
||||
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
let data = { overrides: {}, pack: {} };
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
|
||||
if (r.ok) data = await r.json();
|
||||
} catch (_) { /* offline — fall back to the empty baseline */ }
|
||||
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
|
||||
const pack = data.pack || {};
|
||||
const ov = data.overrides || {};
|
||||
const st = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const o = ov[f] || {};
|
||||
st[f] = {
|
||||
pack: pack[f] || '',
|
||||
value: (o.value != null ? o.value : (pack[f] || '')),
|
||||
locked: !!o.locked,
|
||||
};
|
||||
}
|
||||
song._detailsState = st;
|
||||
// Match→Details bridge: a candidate picked with "Use these values" lands
|
||||
// its fields here as the pending (unsaved) input values, shown pre-filled
|
||||
// for review — the grid never adopts a match silently, so the user still
|
||||
// Saves (or Writes to file).
|
||||
const adopted = song._pendingDetails;
|
||||
if (adopted) {
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
if (f in adopted) st[f].value = String(adopted[f] || '');
|
||||
}
|
||||
song._pendingDetails = null;
|
||||
}
|
||||
paintDetails(body, song);
|
||||
if (adopted) {
|
||||
const s = body.querySelector('[data-df-status]');
|
||||
if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Match→Details bridge: adopt a candidate's display fields into the Details
|
||||
// tab (opt-in — never silent). Pin the match too so the art/canon follow,
|
||||
// then land on Details pre-filled for review.
|
||||
async function useTheseValues(song, cand) {
|
||||
if (!cand) return;
|
||||
// Smart adopt for an English base: KEEP the readable name + title the card
|
||||
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
|
||||
// match is often native script (kanji/kana). Take only what the pack lacks
|
||||
// — album / year / genre — from the match; the pin below still brings the
|
||||
// correct art + identity. The user can still edit any field.
|
||||
song._pendingDetails = {
|
||||
artist: String(song.artist || cand.artist || ''),
|
||||
title: String(song.title || cand.title || ''),
|
||||
album: String(cand.album || song.album || ''),
|
||||
year: String(cand.year || song.year || ''),
|
||||
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
|
||||
};
|
||||
try {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
|
||||
} catch (_) { /* pin is best-effort; the values still populate Details */ }
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
_tab = 'details';
|
||||
renderTabbed();
|
||||
}
|
||||
|
||||
function paintDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const row = ([f, label]) => {
|
||||
const s = st[f];
|
||||
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
|
||||
return '<div class="space-y-1">' +
|
||||
'<div class="flex items-center justify-between">' +
|
||||
'<label class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">' + esc(label) + '</label>' +
|
||||
(isYours
|
||||
? '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary">Yours</span>'
|
||||
: '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-card text-fb-textDim">Pack</span>') +
|
||||
'</div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<input data-df-input="' + f + '" type="text" value="' + esc(s.value) + '" ' +
|
||||
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary" ' +
|
||||
'placeholder="' + esc(s.pack || label) + '">' +
|
||||
'<button data-df-lock="' + f + '" type="button" aria-pressed="' + (s.locked ? 'true' : 'false') + '" ' +
|
||||
'title="' + (s.locked ? 'Locked — auto-match won’t change this field' : 'Lock this field against auto-match') + '" ' +
|
||||
'class="px-2 py-1.5 rounded-md border ' + (s.locked ? 'border-fb-primary text-fb-primary bg-fb-primary/10' : 'border-fb-border/50 text-fb-textDim hover:text-fb-text') + '">' +
|
||||
(s.locked ? '🔒' : '🔓') + '</button>' +
|
||||
'<button data-df-revert="' + f + '" type="button" title="Revert to the pack value" ' +
|
||||
'class="px-2 py-1.5 rounded-md border border-fb-border/50 text-fb-textDim hover:text-fb-text">↺</button>' +
|
||||
'</div></div>';
|
||||
};
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.</p>' +
|
||||
'</div>' +
|
||||
DETAIL_FIELDS.map(row).join('') +
|
||||
'<p data-df-status class="text-xs leading-relaxed"></p>' +
|
||||
'</div>' +
|
||||
'<div class="flex items-center justify-between gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<button data-df-write type="button" title="Write these values into the song file itself — permanent, survives a full rescan. The rest of the pack is untouched." class="text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md px-3 py-2">Write to file</button>' +
|
||||
'<button data-df-save class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Save</button>' +
|
||||
'</div>';
|
||||
body.querySelectorAll('[data-df-input]').forEach((inp) => {
|
||||
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
|
||||
});
|
||||
body.querySelectorAll('[data-df-lock]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelectorAll('[data-df-revert]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
|
||||
body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(body, song));
|
||||
}
|
||||
|
||||
async function saveDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const overrides = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim();
|
||||
const p = String(st[f].pack || '').trim();
|
||||
// Only store a value that differs from the pack; equal / blank clears
|
||||
// the override (the server drops a value-less, unlocked row).
|
||||
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
|
||||
}
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
let ok = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides }),
|
||||
});
|
||||
ok = r.ok;
|
||||
} catch (_) { ok = false; }
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Reflect the new effective values on the in-memory song (keeps the Match
|
||||
// tab header consistent) and repaint the library so the card shows them —
|
||||
// the grid reloads on library:changed (slice 3 overlay does the rest).
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
|
||||
song[f] = (v && v !== p) ? v : (st[f].pack || '');
|
||||
}
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
|
||||
}
|
||||
|
||||
// "Write to file" — bake the shown title/artist/album/year INTO the pack
|
||||
// itself (the one action here that touches the file), via the existing
|
||||
// POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a
|
||||
// rescan). On a real file write the display overrides for those fields are
|
||||
// now redundant, so clear their VALUES (keeping any locks) and re-render —
|
||||
// the field then reads from the file as "Pack". Loose-folder / unwritable
|
||||
// packs fall back to a DB-only update: we say so and keep the overlay.
|
||||
async function writeToFile(body, song) {
|
||||
const st = song._detailsState;
|
||||
const fields = {};
|
||||
for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim();
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const writeBtn = body.querySelector('[data-df-write]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (writeBtn) writeBtn.disabled = true;
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; }
|
||||
let ok = false, persisted = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/meta', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
ok = r.ok;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
persisted = !!(j && j.persisted);
|
||||
} catch (_) { ok = false; }
|
||||
if (writeBtn) writeBtn.disabled = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Keep the in-memory song + grid in step with what was *persisted*, not
|
||||
// the raw input: the server coerces a non-numeric/empty year to "" (see
|
||||
// update_song_meta), so mirror that here or the grid card flashes the
|
||||
// typed text (e.g. "abcd") until the next natural refresh corrects it.
|
||||
const applied = { ...fields };
|
||||
if ('year' in applied) {
|
||||
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
|
||||
applied.year = yr ? String(yr) : '';
|
||||
}
|
||||
for (const f of WRITE_FIELDS) song[f] = applied[f];
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
|
||||
if (persisted) {
|
||||
const clear = {};
|
||||
for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
|
||||
try {
|
||||
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: clear }),
|
||||
});
|
||||
} catch (_) { /* the file write still succeeded; the overlay just lingers */ }
|
||||
await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared
|
||||
const s2 = body.querySelector('[data-df-status]');
|
||||
if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; }
|
||||
} else if (status) {
|
||||
status.className = 'text-xs leading-relaxed text-fb-textDim';
|
||||
status.textContent = 'Saved to the library — this pack’s file couldn’t be written, so it may revert on a full rescan.';
|
||||
}
|
||||
}
|
||||
|
||||
// Cover-art tab: the current art + a button that hands off to the shared
|
||||
// cover picker (image-picker.js, its own z-[200] modal). A pick there
|
||||
// refreshes every <img> for this song's art — including this thumbnail — so
|
||||
// there's nothing to wire back.
|
||||
function renderCoverTab(body, song) {
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0 flex flex-col items-center text-center">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-40 h-40 rounded-xl object-cover bg-fb-card">' +
|
||||
'<p class="text-sm text-fb-textDim max-w-sm">Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.</p>' +
|
||||
'<button data-cover-open class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Choose cover art…</button>' +
|
||||
'</div>';
|
||||
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Silent-on-success: the chart just leaves the queue and the next one
|
||||
// renders; the last one renders the done state. No toasts, no sounds.
|
||||
function settle(song) {
|
||||
if (_single) {
|
||||
// Popup Match tab: a pinned identity can change the art/canon — nudge
|
||||
// the grid to repaint (silent otherwise, like the queue flow).
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
const i = _queue.indexOf(song);
|
||||
if (i >= 0) _queue.splice(i, 1);
|
||||
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
|
||||
refreshChip();
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
async function runSearch(panel, song) {
|
||||
const input = panel.querySelector('[data-mr-search-input]');
|
||||
const out = panel.querySelector('[data-mr-search-results]');
|
||||
if (!input || !out) return;
|
||||
const qRaw = input.value.trim();
|
||||
if (!qRaw) return;
|
||||
// "Artist – Title" splits on the first dash; a plain phrase searches
|
||||
// as a title, which MusicBrainz handles well enough.
|
||||
const m = qRaw.split(/\s+[–—-]\s+/);
|
||||
const artist = m.length > 1 ? m[0] : '';
|
||||
const title = m.length > 1 ? m.slice(1).join(' - ') : qRaw;
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Searching…</p>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/search?artist=' + enc(artist) +
|
||||
'&title=' + enc(title) + '&filename=' + enc(song.filename));
|
||||
if (r.status === 503) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">MusicBrainz is unavailable — try again later.</p>';
|
||||
return;
|
||||
}
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">No results.</p>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
|
||||
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
|
||||
// and render the hits into the same search-results area. The reliable path
|
||||
// when text search can't tell the studio take from live/comp versions.
|
||||
async function runIdentify(panel, song) {
|
||||
const out = panel.querySelector('[data-mr-search-results]');
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
if (!out) return;
|
||||
sp?.classList.remove('hidden'); // give the results somewhere to render
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
|
||||
let body = null, status = 0;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
|
||||
status = r.status;
|
||||
body = await r.json().catch(() => null);
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
// Honest states — never a fake hit. Each says plainly WHICH outcome this
|
||||
// is, so an empty result reads as "it ran, found nothing" (not "broken")
|
||||
// and points at the manual fallback when there's nothing to pick.
|
||||
const note = (html) => { out.innerHTML = '<p class="text-xs text-fb-textDim leading-relaxed">' + html + '</p>'; };
|
||||
const manual = _single
|
||||
? ' Try <b class="text-fb-text">Search</b>, or just set the album in <b class="text-fb-text">Details</b> and the cover in <b class="text-fb-text">Cover art</b> by hand.'
|
||||
: ' Try <b class="text-fb-text">Search instead</b>.';
|
||||
if (status === 412 || (body && body.needs_setup)) {
|
||||
note('Audio identification is <b class="text-fb-text">off</b>. Turn it on and add a free AcoustID API key in Settings → Library to use it.');
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
note('This pack has <b class="text-fb-text">no full mix to fingerprint</b> (it\'s chart-only or stems-only).' + manual);
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.');
|
||||
return;
|
||||
}
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
note('<span class="text-fb-good">✓ Fingerprinted the audio</span> — but AcoustID has <b class="text-fb-text">no match</b> for this exact recording (common for obscure or import tracks).' + manual);
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-good mb-1">✓ Fingerprint matches (AcoustID)</div>' +
|
||||
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
|
||||
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function post(url, payload) {
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload || {}),
|
||||
});
|
||||
} catch (_) { /* offline — the row simply stays queued */ }
|
||||
}
|
||||
|
||||
// ── Settings → Library → "Metadata matching" card ────────────────────────
|
||||
// Markup lives statically in index.html (the v3 settings pattern); this
|
||||
// wires it. All null-guarded so v2 (which lacks the elements) no-ops.
|
||||
function wireSettingsCard() {
|
||||
const sel = document.getElementById('enrich-threshold');
|
||||
const order = document.getElementById('enrich-review-order');
|
||||
const btn = document.getElementById('enrich-match-now');
|
||||
// Boolean toggles, element id → settings key. enrich-enabled is the
|
||||
// master background switch; the rest are the R1 scraper options
|
||||
// (per-source + per-field auto-apply).
|
||||
const toggles = [
|
||||
['enrich-enabled', 'enrich_enabled'],
|
||||
['enrich-src-musicbrainz', 'enrich_src_musicbrainz'],
|
||||
['enrich-src-caa', 'enrich_src_caa'],
|
||||
['enrich-apply-names', 'enrich_apply_names'],
|
||||
['enrich-apply-year', 'enrich_apply_year'],
|
||||
['enrich-apply-genres', 'enrich_apply_genres'],
|
||||
['enrich-apply-art', 'enrich_apply_art'],
|
||||
// Artist pages (PR-B): the page itself — local-only, default ON.
|
||||
['artist-pages-enabled', 'artist_pages_enabled'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
// Default-OFF toggles load with the opposite absent-key semantic
|
||||
// (checked only when explicitly true): the external-links row is
|
||||
// opt-IN per the dev-chat thread.
|
||||
const optInToggles = [
|
||||
['artist-external-links', 'artist_external_links'],
|
||||
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
|
||||
['acoustid-enabled', 'acoustid_enabled'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
const acoustidKeyEl = document.getElementById('acoustid-api-key');
|
||||
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
if (r.ok) {
|
||||
const cfg = await r.json();
|
||||
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
|
||||
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
|
||||
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
|
||||
if (sel) {
|
||||
const t = Number(cfg.enrich_auto_threshold);
|
||||
const want = Number.isFinite(t) ? t : 0.9;
|
||||
// Snap to the nearest offered option.
|
||||
let best = sel.options[0];
|
||||
for (const o of sel.options) {
|
||||
if (Math.abs(Number(o.value) - want) < Math.abs(Number(best.value) - want)) best = o;
|
||||
}
|
||||
if (best) sel.value = best.value;
|
||||
}
|
||||
if (order) {
|
||||
const v = String(cfg.enrich_review_order || 'missing_first');
|
||||
order.value = ['missing_first', 'artist', 'recent'].includes(v) ? v : 'missing_first';
|
||||
}
|
||||
}
|
||||
} catch (_) { /* leave markup defaults */ }
|
||||
refreshChip(); // also fills #enrich-status
|
||||
})();
|
||||
const save = (key, value) => post('/api/settings', { [key]: value });
|
||||
for (const [el, key] of toggles.concat(optInToggles)) {
|
||||
el.addEventListener('change', () => save(key, !!el.checked));
|
||||
}
|
||||
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
|
||||
order?.addEventListener('change', () => save('enrich_review_order', order.value));
|
||||
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
|
||||
btn?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/kick');
|
||||
const line = document.getElementById('enrich-status');
|
||||
if (line) line.textContent = 'Matching…';
|
||||
setTimeout(refreshChip, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Stop the 5s poll when the library screen is left — the progress line and
|
||||
// chip only live in the songs toolbar, so polling off-screen is pure waste
|
||||
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
|
||||
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
|
||||
// this stays self-contained. Same single-guarded-interval invariant as
|
||||
// refreshChip — no double-interval, cleared to null.
|
||||
function wireScreenTeardown() {
|
||||
const sm = window.feedBack;
|
||||
if (!sm || typeof sm.on !== 'function') return;
|
||||
sm.on('screen:changed', (e) => {
|
||||
const id = e && e.detail && e.detail.id;
|
||||
if (id === 'v3-songs') {
|
||||
refreshChip(); // returning while a pass runs re-arms the poll
|
||||
} else if (_pollTimer) {
|
||||
clearInterval(_pollTimer);
|
||||
_pollTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}, { once: true });
|
||||
} else {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
}
|
||||
|
||||
window.__fbMatchReviewChip = refreshChip;
|
||||
window.__fbOpenMatchReview = openModal;
|
||||
window.__fbFixMatch = fixMatch;
|
||||
})();
|
||||
@@ -42,7 +42,7 @@
|
||||
id: 'instrument', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
|
||||
title: 'Choose your instrument',
|
||||
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
|
||||
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
|
||||
},
|
||||
{
|
||||
id: 'tuner', shape: 'spotlight', position: 'bottom',
|
||||
|
||||
+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() {
|
||||
|
||||
+234
-15
@@ -37,26 +37,59 @@
|
||||
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
|
||||
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
|
||||
if (!arts.length) {
|
||||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
|
||||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '</div>';
|
||||
}
|
||||
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
|
||||
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
|
||||
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
|
||||
}
|
||||
|
||||
// The slot's pinned-arrangement INDEX, resolved from the stored NAME
|
||||
// against the slot's current chart (names survive rescans; an index
|
||||
// wouldn't). null = no pin / the name isn't on this chart → full song.
|
||||
function _slotArrIndex(s) {
|
||||
if (!s.arrangement || !Array.isArray(s.arrangements)) return null;
|
||||
const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement));
|
||||
return (m && m.index != null) ? m.index : null;
|
||||
}
|
||||
|
||||
function songRow(s, opts) {
|
||||
opts = opts || {};
|
||||
const handle = opts.draggable
|
||||
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
|
||||
const tuning = s.tuning_name
|
||||
? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
return '<li data-fn="' + esc(s.filename) + '"' + (opts.draggable ? ' draggable="true"' : '') +
|
||||
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group">' +
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
|
||||
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
|
||||
// the work's current keeper when the pinned file is gone) with its
|
||||
// pinned arrangement (data-play-arr); `missing` = the whole work left
|
||||
// the library, so the row dims and loses play (denominator stays
|
||||
// honest). ▾ opens the slot editor (chart + arrangement pin).
|
||||
const isAlbum = !!opts.album;
|
||||
const missing = isAlbum && !!s.missing;
|
||||
const playFn = s.resolved_filename || s.filename;
|
||||
const arrIdx = isAlbum ? _slotArrIndex(s) : null;
|
||||
const playAttrs = isAlbum && !missing
|
||||
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
|
||||
: '';
|
||||
const acc = (isAlbum && typeof opts.acc === 'number')
|
||||
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
|
||||
: '';
|
||||
const pin = (isAlbum && s.arrangement)
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
|
||||
const orphan = (isAlbum && s.resolved_from_orphan)
|
||||
? '<span class="ml-2 text-[0.625rem] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
|
||||
const slotBtn = (isAlbum && !missing)
|
||||
? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
|
||||
return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
|
||||
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group' + (missing ? ' opacity-50' : '') + '">' +
|
||||
handle +
|
||||
'<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
|
||||
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + '</span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span></span>' +
|
||||
'<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>' +
|
||||
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + pin + orphan + '</span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '</span></span>' +
|
||||
acc +
|
||||
(missing ? '' : '<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>') +
|
||||
slotBtn +
|
||||
'<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
|
||||
'</li>';
|
||||
}
|
||||
@@ -68,7 +101,14 @@
|
||||
// playSong decodeURIComponent()s its arg for the highway WS, so
|
||||
// pass an encoded filename (like the rest of v3) — a raw name
|
||||
// with %/#/?/ in it would otherwise misroute or throw.
|
||||
if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
|
||||
// Album slots override the play target (data-play-fn = the
|
||||
// orphan-resolved chart) + pass the pinned arrangement index;
|
||||
// mix/saved rows carry neither attribute and behave as before.
|
||||
const pfn = li.getAttribute('data-play-fn') || fn;
|
||||
const pa = li.getAttribute('data-play-arr');
|
||||
if (typeof window.playSong === 'function') {
|
||||
window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa));
|
||||
}
|
||||
});
|
||||
li.querySelector('[data-remove]')?.addEventListener('click', async () => {
|
||||
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
|
||||
@@ -106,7 +146,10 @@
|
||||
const lists = (await jget('/api/playlists')) || [];
|
||||
root.innerHTML =
|
||||
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
|
||||
'<div class="flex items-center justify-end mb-6">' +
|
||||
'<div class="flex items-center justify-end gap-2 mb-6">' +
|
||||
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
|
||||
// per track — same machinery as a playlist, kind='album'.
|
||||
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
|
||||
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
|
||||
'</div>' +
|
||||
(lists.length
|
||||
@@ -114,7 +157,7 @@
|
||||
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
playlistCoverHtml(p) +
|
||||
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>'
|
||||
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
|
||||
'</div>';
|
||||
@@ -124,6 +167,12 @@
|
||||
await jsend('POST', '/api/playlists', { name });
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => {
|
||||
const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim();
|
||||
if (!name) return;
|
||||
await jsend('POST', '/api/playlists', { name, kind: 'album' });
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelectorAll('[data-pl]').forEach((b) =>
|
||||
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
|
||||
}
|
||||
@@ -134,26 +183,108 @@
|
||||
const pl = await jget('/api/playlists/' + pid);
|
||||
if (!pl) { renderPlaylists(); return; }
|
||||
const isSystem = !!pl.system_key;
|
||||
const isAlbum = pl.kind === 'album';
|
||||
// Set-scoped repertoire (P6, §7.2): an album is a bounded practice SET
|
||||
// with a denominator — "N of M mastered", per-track accuracy, never one
|
||||
// album score. Same 0.9 threshold as the library meter/green badge.
|
||||
let best = {};
|
||||
if (isAlbum) best = (await jget('/api/stats/best')) || {};
|
||||
const slotAcc = (s) => best[s.resolved_filename || s.filename];
|
||||
let meter = '';
|
||||
if (isAlbum && pl.songs.length) {
|
||||
const tracks = pl.songs.filter((s) => !s.missing);
|
||||
const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length;
|
||||
const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length;
|
||||
const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0;
|
||||
meter =
|
||||
'<div class="mb-6">' +
|
||||
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
|
||||
'<span class="text-sm font-semibold text-fb-text">Album repertoire</span>' +
|
||||
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + tracks.length + ' mastered' +
|
||||
(started ? ' · ' + started + ' in progress' : '') + '</span></div>' +
|
||||
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
root.innerHTML =
|
||||
'<div class="max-w-3xl mx-auto p-6 md:p-8">' +
|
||||
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
|
||||
'<div class="flex items-center justify-between mb-6 gap-3">' +
|
||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
|
||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
|
||||
'<div class="flex gap-2 shrink-0 items-center">' +
|
||||
(pl.songs.length
|
||||
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
|
||||
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
|
||||
'</button>' +
|
||||
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
|
||||
: '') +
|
||||
(isSystem ? '' :
|
||||
'<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>' +
|
||||
meter +
|
||||
(pl.songs.length
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') +
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||
'</div>';
|
||||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
||||
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
|
||||
// one preference, not per playlist. The queue is shuffled once when
|
||||
// Play starts (playQueue.start's shuffle opt); the stored playlist
|
||||
// order is never touched.
|
||||
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
|
||||
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
|
||||
const paintShuffle = () => {
|
||||
if (!shuffleBtn) return;
|
||||
const on = shuffleOn();
|
||||
shuffleBtn.className = on
|
||||
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
|
||||
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
|
||||
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
|
||||
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
};
|
||||
paintShuffle();
|
||||
shuffleBtn?.addEventListener('click', () => {
|
||||
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
|
||||
paintShuffle();
|
||||
});
|
||||
// Play all: start the play-queue with this playlist's songs (auto-advances
|
||||
// track to track). Falls back to playing the first song on an older core
|
||||
// without the queue, so the button always does something. An ALBUM plays
|
||||
// each slot's resolved chart with its pinned arrangement (playQueue's
|
||||
// per-index arrangements array, #685) and skips missing works.
|
||||
root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
|
||||
const files = [], arrs = [];
|
||||
(pl.songs || []).forEach((s) => {
|
||||
if (isAlbum && s.missing) return;
|
||||
const fn = s.resolved_filename || s.filename;
|
||||
if (!fn) return;
|
||||
files.push(fn);
|
||||
const idx = isAlbum ? _slotArrIndex(s) : null;
|
||||
arrs.push(idx == null ? undefined : idx);
|
||||
});
|
||||
if (!files.length) return;
|
||||
if (window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.start(files, isAlbum
|
||||
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||
: { source: pl.name, shuffle: shuffleOn() });
|
||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
|
||||
if (listEl && isAlbum) {
|
||||
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
|
||||
li.querySelector('[data-slot]')?.addEventListener('click', () => {
|
||||
const fn = li.getAttribute('data-fn');
|
||||
const slot = (pl.songs || []).find((x) => x.filename === fn);
|
||||
if (slot) openSlotPicker(pid, slot, () => renderPlaylistDetail(pid));
|
||||
});
|
||||
});
|
||||
}
|
||||
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
|
||||
const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
|
||||
if (!name) return;
|
||||
@@ -186,6 +317,94 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Curated-album slot editor (P6, §7.2) ─────────────────────────────────
|
||||
// Pins THIS slot's chart + arrangement. The per-slot pick is deliberately
|
||||
// independent of the work's global preferred — a rehearsed set must stay
|
||||
// the same notes even if the global keeper is re-picked later. Charts come
|
||||
// from the work-charts API; the arrangement pin is stored as a NAME (it
|
||||
// survives rescans; the index is resolved at play). A compact overlay for
|
||||
// now — unifying with the library's Charts drawer (slot-scoped mode) is a
|
||||
// follow-up once the in-flight drawer changes land.
|
||||
async function openSlotPicker(pid, slot, onChange) {
|
||||
const curFn = slot.resolved_filename || slot.filename;
|
||||
let wk = slot.work_key;
|
||||
if (!wk) {
|
||||
const w = await jget('/api/chart/' + encodeURIComponent(curFn) + '/work');
|
||||
wk = w && w.work_key;
|
||||
}
|
||||
const charts = wk ? await jget('/api/work/' + encodeURIComponent(wk) + '/charts') : null;
|
||||
const chartList = (charts && Array.isArray(charts.charts)) ? charts.charts : [];
|
||||
const radio = (name, value, checked, label, sub) =>
|
||||
'<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
|
||||
'<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
|
||||
'<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
|
||||
(sub ? '<span class="block text-[0.625rem] text-fb-textDim truncate">' + sub + '</span>' : '') +
|
||||
'</span></label>';
|
||||
// Checked = the stored pin; an orphaned slot (stored file gone from the
|
||||
// list) pre-checks the chart it currently resolves to, so Apply re-pins
|
||||
// what's actually playing.
|
||||
const slotInList = chartList.some((x) => x.filename === slot.filename);
|
||||
const chartRows = chartList.map((c) => radio(
|
||||
'slot-chart', c.filename,
|
||||
c.filename === slot.filename || (!slotInList && c.filename === curFn),
|
||||
esc(c.title) + (c.is_representative ? ' <span class="text-[0.625rem] text-fb-primary">● preferred</span>' : ''),
|
||||
esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
|
||||
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[0.625rem] text-fb-textDim">(default)</span>', '')]
|
||||
.concat((slot.arrangements || []).map((a) => {
|
||||
const name = (a && (a.smart_name || a.name)) || '';
|
||||
if (!name) return '';
|
||||
return radio('slot-arr', name,
|
||||
slot.arrangement === name || slot.arrangement === a.name,
|
||||
esc(name), '');
|
||||
})).join('');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
|
||||
overlay.innerHTML =
|
||||
'<div class="bg-fb-card w-full max-w-md rounded-xl border border-fb-border/60 p-5 space-y-4 max-h-[80vh] overflow-y-auto v3-scroll">' +
|
||||
'<div class="flex items-center justify-between gap-2">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text truncate">' + esc(slot.title) + '</h3>' +
|
||||
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none" aria-label="Close">✕</button></div>' +
|
||||
(chartRows
|
||||
? '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Chart for this slot</div>' + chartRows + '</div>'
|
||||
: '') +
|
||||
'<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Arrangement</div>' + arrRows + '</div>' +
|
||||
'<div data-err class="hidden text-xs text-red-400"></div>' +
|
||||
'<div class="flex justify-end gap-2">' +
|
||||
'<button type="button" data-cancel class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Cancel</button>' +
|
||||
'<button type="button" data-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Apply</button>' +
|
||||
'</div></div>';
|
||||
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
||||
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||
document.addEventListener('keydown', onKey);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('[data-x]').addEventListener('click', close);
|
||||
overlay.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
overlay.querySelector('[data-apply]').addEventListener('click', async () => {
|
||||
const chart = overlay.querySelector('input[name="slot-chart"]:checked');
|
||||
const arr = overlay.querySelector('input[name="slot-arr"]:checked');
|
||||
const body = {};
|
||||
if (chart && chart.value && chart.value !== slot.filename) body.chart_filename = chart.value;
|
||||
if (arr) {
|
||||
const v = arr.value || null;
|
||||
if (v !== (slot.arrangement || null)) body.arrangement = v;
|
||||
}
|
||||
if (Object.keys(body).length) {
|
||||
// jsend → null on a non-2xx (e.g. swap-to-other-work rejected, or
|
||||
// the resolved target duplicates another slot's pin). Surfacing it
|
||||
// and keeping the picker open beats silently closing "as saved".
|
||||
const res = await jsend('PATCH', '/api/playlists/' + pid + '/songs/' + encodeURIComponent(slot.filename), body);
|
||||
if (!res) {
|
||||
const err = overlay.querySelector('[data-err]');
|
||||
if (err) { err.textContent = 'Could not update this slot.'; err.classList.remove('hidden'); }
|
||||
return; // keep the picker open — not a success
|
||||
}
|
||||
}
|
||||
close();
|
||||
onChange();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ── #v3-saved ─────────────────────────────────────────────────────────--
|
||||
async function renderSaved() {
|
||||
const root = document.getElementById('v3-saved');
|
||||
|
||||
+51
-13
@@ -82,10 +82,10 @@
|
||||
'<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
|
||||
'<div class="flex items-end gap-2">' +
|
||||
'<div class="flex flex-col leading-none">' +
|
||||
'<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
|
||||
'<span class="text-gray-400 text-[0.625rem] font-medium">Rank:</span>' +
|
||||
'<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
|
||||
'<div class="flex items-end gap-1">' + bars + '</div>' +
|
||||
'<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
|
||||
'<span class="text-[0.625rem] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
|
||||
'</div></div></button>';
|
||||
// Equipped avatar frame (spec 010 cosmetics).
|
||||
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
|
||||
@@ -178,7 +178,7 @@
|
||||
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
|
||||
'</div>';
|
||||
const playerIdFooter = (_profile && _profile.player_hash
|
||||
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
? '<p class="text-center text-[0.625rem] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
|
||||
: '');
|
||||
root.innerHTML =
|
||||
'<div class="max-w-4xl mx-auto p-6 md:p-8">' +
|
||||
@@ -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', {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var RESET_MAP = {
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
|
||||
+13
-3
@@ -36,7 +36,7 @@
|
||||
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
|
||||
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
|
||||
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
|
||||
{ key: 'songs', screen: 'v3-songs', label: 'Songs', group: 'LIBRARY', icon: 'disc' },
|
||||
{ key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' },
|
||||
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
|
||||
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
|
||||
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
|
||||
@@ -179,7 +179,7 @@
|
||||
const items = NAV.filter((n) => n.group === group);
|
||||
if (!items.length) continue;
|
||||
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
html += '<div><div class="v3-nav-group px-3 mb-1 text-[0.625rem] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
||||
}
|
||||
nav.innerHTML = html;
|
||||
@@ -192,6 +192,9 @@
|
||||
}
|
||||
|
||||
// ── Topbar ───────────────────────────────────────────────────────────---
|
||||
// Funding cleared to come back online 2026-06-30 (offending functionality
|
||||
// removed). feedBack-branded Patreon page.
|
||||
const PATREON_URL = 'https://patreon.com/got_feedback';
|
||||
function renderTopbar() {
|
||||
const bar = document.getElementById('v3-topbar');
|
||||
if (!bar) return;
|
||||
@@ -209,6 +212,12 @@
|
||||
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
|
||||
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
|
||||
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
|
||||
// Support Us! — stays on this top utility row (NOT the title row),
|
||||
// pushed to the right with ml-auto; hidden on the smallest widths.
|
||||
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
|
||||
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
|
||||
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
|
||||
'Support Us!</a>' +
|
||||
'</div>' +
|
||||
// Row 2 — page header: title + ONLY the tuner/instrument/profile
|
||||
// badge cluster on the same line as the header.
|
||||
@@ -329,7 +338,8 @@
|
||||
|
||||
// ── Boot ────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
|
||||
var _v3brand = document.getElementById('v3-brand');
|
||||
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
|
||||
renderSidebar();
|
||||
renderTopbar();
|
||||
ensureBackdrop();
|
||||
|
||||
+3087
-195
File diff suppressed because it is too large
Load Diff
+112
-2
@@ -23,7 +23,24 @@
|
||||
const OPACITY = { 95: '0.95', 90: '0.9', 80: '0.8', 70: '0.7', 60: '0.6',
|
||||
50: '0.5', 40: '0.4', 30: '0.3', 20: '0.2', 10: '0.1' };
|
||||
|
||||
// Default fb palette (mirrors tailwind.config.js `fb`). Emitted as
|
||||
// always-present `--fbv-*` on :root so `var(--fbv-accent)` resolves even
|
||||
// un-themed — the un-themed look is unchanged (the fb-* utilities still use
|
||||
// their compiled defaults; this only hands plugins a stable host token to
|
||||
// read + derive from). Adds two keystone ROLES the palette lacked:
|
||||
// `on-accent` (a foreground legible ON the accent fill — the missing piece
|
||||
// behind white-on-accent contrast bugs) and `focus-ring`.
|
||||
const DEFAULTS = {
|
||||
bg: '#0f172a', sidebar: '#111827', card: '#1e293b', cardMuted: '#0b1220',
|
||||
primary: '#0ea5e9', primaryHi: '#38bdf8', accent: '#ef4444',
|
||||
text: '#f8fafc', textDim: '#94a3b8', border: '#334155',
|
||||
good: '#22c55e', mid: '#eab308', low: '#ef4444', gold: '#e8c040',
|
||||
'on-accent': '#f8fafc', 'focus-ring': '#38bdf8',
|
||||
};
|
||||
|
||||
let _frameStyle = ''; // equipped avatar-frame CSS fragment ('' = none)
|
||||
let _themeId = null; // equipped theme id (null = default look)
|
||||
let _themeCaps = null; // equipped theme's declared device capabilities, or null
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
||||
@@ -73,12 +90,14 @@
|
||||
return 'html[data-fb-theme] {\n' + vars + '}\n' + rules;
|
||||
}
|
||||
|
||||
function apply(payload) {
|
||||
function apply(payload, meta) {
|
||||
const colors = payload && payload.colors;
|
||||
let styleEl = document.getElementById(STYLE_ID);
|
||||
if (!colors) {
|
||||
if (styleEl) styleEl.remove();
|
||||
document.documentElement.removeAttribute('data-fb-theme');
|
||||
_themeId = null; _themeCaps = null;
|
||||
_emitThemeChanged();
|
||||
return;
|
||||
}
|
||||
if (!styleEl) {
|
||||
@@ -88,6 +107,13 @@
|
||||
}
|
||||
styleEl.textContent = cssFor(colors);
|
||||
document.documentElement.setAttribute('data-fb-theme', '1');
|
||||
// Track identity + declared device capabilities for the read API. A
|
||||
// theme MAY carry `capabilities` (e.g. {glow:false}) in its payload;
|
||||
// recolor-only themes carry none (→ default affordances reported).
|
||||
_themeId = (meta && meta.id) || (payload && payload.id) || 'custom';
|
||||
_themeCaps = (payload && payload.capabilities)
|
||||
|| (meta && meta.capabilities) || null;
|
||||
_emitThemeChanged();
|
||||
}
|
||||
|
||||
function setFrame(payload) {
|
||||
@@ -106,7 +132,7 @@
|
||||
|
||||
function applyCosmetics(cosmetics) {
|
||||
cosmetics = cosmetics || {};
|
||||
apply((cosmetics.theme || {}).payload || null);
|
||||
apply((cosmetics.theme || {}).payload || null, cosmetics.theme || null);
|
||||
setFrame((cosmetics.avatar_frame || {}).payload || null);
|
||||
}
|
||||
|
||||
@@ -123,6 +149,83 @@
|
||||
} catch (e) { /* offline — keep current look */ }
|
||||
}
|
||||
|
||||
// ── Host theme READ surface (window.feedBack.theme) ──────────────────────
|
||||
// The apply side stays on window.v3Theme; this is the read/capability side
|
||||
// plugins consume so a feature can render correctly under any theme instead
|
||||
// of binding to the one the dev happened to see. See docs/host-theme-contract.md.
|
||||
|
||||
// Always-present `--fbv-*` defaults on :root (see DEFAULTS). Additive: the
|
||||
// un-themed look is unchanged; this only makes var(--fbv-*) resolve so a
|
||||
// plugin can derive surfaces from host tokens whether or not a theme is on.
|
||||
function _injectDefaults() {
|
||||
if (document.getElementById('fb-theme-defaults')) return;
|
||||
let vars = '';
|
||||
for (const key in DEFAULTS) {
|
||||
const rgb = hexToRgb(DEFAULTS[key]);
|
||||
if (rgb) vars += ' --fbv-' + key + ': ' + rgb + ';\n';
|
||||
}
|
||||
const el = document.createElement('style');
|
||||
el.id = 'fb-theme-defaults';
|
||||
el.textContent = ':root {\n' + vars + '}\n';
|
||||
(document.head || document.documentElement).appendChild(el);
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
try {
|
||||
return !!(window.matchMedia
|
||||
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
// Live snapshot of the resolved role tokens ("r g b" triplets; use as
|
||||
// rgb(var(--fbv-<key>)) in CSS). Reflects the themed values when a theme is
|
||||
// equipped, the :root defaults otherwise.
|
||||
function _readTokens() {
|
||||
const t = {};
|
||||
try {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
for (const key in DEFAULTS) {
|
||||
const v = cs.getPropertyValue('--fbv-' + key).trim();
|
||||
if (v) t[key] = v;
|
||||
}
|
||||
} catch (e) { /* no computed style available yet */ }
|
||||
return t;
|
||||
}
|
||||
|
||||
// Whether the ACTIVE theme permits decorative DEVICES — the signal a feature
|
||||
// reads to pick a device (glow vs. solid) rather than hardcoding one. Today's
|
||||
// recolor-only themes declare nothing → default affordances; a theme may opt
|
||||
// out via `capabilities` in its payload (e.g. a clean theme = {glow:false}).
|
||||
// `motion` is additionally gated by the OS reduced-motion preference.
|
||||
function capabilities() {
|
||||
const c = _themeCaps || {};
|
||||
const allowMotion = c.motion !== undefined ? !!c.motion : true;
|
||||
return {
|
||||
glow: c.glow !== undefined ? !!c.glow : true,
|
||||
gradients: c.gradients !== undefined ? !!c.gradients : true,
|
||||
motion: allowMotion && !prefersReducedMotion(),
|
||||
};
|
||||
}
|
||||
|
||||
function get() {
|
||||
return {
|
||||
id: _themeId,
|
||||
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
|
||||
tokens: _readTokens(),
|
||||
};
|
||||
}
|
||||
|
||||
function _emitThemeChanged() {
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
window.feedBack.emit('theme:changed', {
|
||||
id: _themeId,
|
||||
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
|
||||
tokens: _readTokens(),
|
||||
capabilities: capabilities(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.v3Theme = {
|
||||
apply, // preview/apply a theme payload directly (null = default)
|
||||
applyCosmetics, // apply a {theme, avatar_frame} equipped map
|
||||
@@ -131,6 +234,13 @@
|
||||
refresh, // re-read equipped cosmetics from /api/profile
|
||||
};
|
||||
|
||||
// Host-owned theme READ surface. Attached defensively so it survives the
|
||||
// feedBack bus being (re)built by capabilities.js regardless of load order
|
||||
// (the bus constructor copies pre-existing keys onto itself).
|
||||
window.feedBack = window.feedBack || {};
|
||||
window.feedBack.theme = { get, capabilities, prefersReducedMotion };
|
||||
|
||||
_injectDefaults();
|
||||
refresh();
|
||||
// Re-apply when an equip/unequip happens anywhere (shop screen, capability
|
||||
// command from a plugin).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user