mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-17 18:30:27 +00:00
Compare commits
187
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbbf3fb94 | ||
|
|
a6efd4ee5e | ||
|
|
6c15ed9dda | ||
|
|
a0beb67b0d | ||
|
|
273412744a | ||
|
|
4bb5d21a40 | ||
|
|
b7e36cc633 | ||
|
|
a55dca7893 | ||
|
|
06e4fe335a | ||
|
|
d475899c5a | ||
|
|
7180eff05d | ||
|
|
7623ad85e3 | ||
|
|
b02c760aec | ||
|
|
59bccefe00 | ||
|
|
c58c40f1ed | ||
|
|
66aa829362 | ||
|
|
e230629770 | ||
|
|
930b492fa5 | ||
|
|
4b2e4172c5 | ||
|
|
f401127c20 | ||
|
|
10c7ec8a9d | ||
|
|
4a45ed8782 | ||
|
|
1f3bde73d1 | ||
|
|
d060c49238 | ||
|
|
9b98d78cbe | ||
|
|
4b87db0c17 | ||
|
|
0a91f4f1b1 | ||
|
|
a993d2b291 | ||
|
|
e6b2f86068 | ||
|
|
733d772154 | ||
|
|
90283f9d3a | ||
|
|
97799fff37 | ||
|
|
c4eebe1c9f | ||
|
|
f69c544eea | ||
|
|
81b366746e | ||
|
|
4e060cbf7b | ||
|
|
6050b6262b | ||
|
|
c7f7c88c62 | ||
|
|
f75e91088f | ||
|
|
8ea123deaf | ||
|
|
5e401afe87 | ||
|
|
a1f7ad5e68 | ||
|
|
84d33769b8 | ||
|
|
d5f622f31b | ||
|
|
7bce521a5b | ||
|
|
cafb1ee790 | ||
|
|
f6e9727b04 | ||
|
|
b739e44e3d | ||
|
|
2ae46fe754 | ||
|
|
1033de87ce | ||
|
|
c264a66e5d | ||
|
|
c46b6484bf | ||
|
|
2a455702b8 | ||
|
|
a57b62378c | ||
|
|
d2847ab153 | ||
|
|
47e85d61c7 | ||
|
|
95f4bb80e1 | ||
|
|
eef58c88c3 | ||
|
|
1a7e2bf084 | ||
|
|
32c00cdd78 | ||
|
|
8297afc449 | ||
|
|
59bcf338a3 | ||
|
|
03e1c1d57e | ||
|
|
0e3522ccc3 | ||
|
|
e0270e5c30 | ||
|
|
605dbdfd25 | ||
|
|
a9be210f77 | ||
|
|
35c0d0ea0d | ||
|
|
270cb39f41 | ||
|
|
23c509322b | ||
|
|
fcdb4867d6 | ||
|
|
be49465540 | ||
|
|
05be9ebdbe | ||
|
|
f7942f3689 | ||
|
|
1cd6f2dd65 | ||
|
|
39d1a8cb9b | ||
|
|
32ed564006 | ||
|
|
1712803dc7 | ||
|
|
00fce2772d | ||
|
|
1745b13ba7 | ||
|
|
cc75cb876a | ||
|
|
f0d9c3abc0 | ||
|
|
2413991c5a | ||
|
|
1c077c9ab7 | ||
|
|
3717e4338d | ||
|
|
0b4b174d33 | ||
|
|
2f2a095e4c | ||
|
|
e14ef64224 | ||
|
|
365cec1d29 | ||
|
|
1702afa379 | ||
|
|
917d81c2d2 | ||
|
|
939c98214b | ||
|
|
4e0e3c5417 | ||
|
|
8ef97708ef | ||
|
|
e729c44d5b | ||
|
|
2991612531 | ||
|
|
af611770aa | ||
|
|
ea9da0acde | ||
|
|
be473dc7af | ||
|
|
0d35228d56 | ||
|
|
dd1927e27b | ||
|
|
7c897e9f2b | ||
|
|
6272af8d33 | ||
|
|
831117fb96 | ||
|
|
6cc0312661 | ||
|
|
329cc86315 | ||
|
|
d876ded00f | ||
|
|
18d77d2d41 | ||
|
|
5921157f35 | ||
|
|
3e57ba0345 | ||
|
|
b85496fe58 | ||
|
|
4027c31a61 | ||
|
|
0fc6a4beed | ||
|
|
d26347981c | ||
|
|
45caa86ab8 | ||
|
|
8f1906a0c1 | ||
|
|
8b6829a946 | ||
|
|
3050c7b1d3 | ||
|
|
ba796b0f27 | ||
|
|
ddc06ff1e7 | ||
|
|
ac5c5ad20d | ||
|
|
f8012a8ce4 | ||
|
|
99b974a5a1 | ||
|
|
7ffa6e2c51 | ||
|
|
3832a5762b | ||
|
|
a60dcd10c2 | ||
|
|
5dcf39cd62 | ||
|
|
c485f02211 | ||
|
|
203f82b6fe | ||
|
|
0158286d06 | ||
|
|
1e2ce29cf6 | ||
|
|
b54b65d35c | ||
|
|
ab2e68a638 | ||
|
|
d806d12c22 | ||
|
|
32d723b774 | ||
|
|
ceb1e143cd | ||
|
|
d0626f5618 | ||
|
|
0dc9fd7ba8 | ||
|
|
22332bef22 | ||
|
|
342def3851 | ||
|
|
a0278bd3a7 | ||
|
|
67e6b25c43 | ||
|
|
503716acbf | ||
|
|
41bb4482fe | ||
|
|
0955f0b6f2 | ||
|
|
3a50e593bf | ||
|
|
5049be0523 | ||
|
|
de2a42bd35 | ||
|
|
671aba950c | ||
|
|
95d6d8a46e | ||
|
|
f43779c99e | ||
|
|
82aa8a757e | ||
|
|
cb425ed48d | ||
|
|
b74a364857 | ||
|
|
859b0036e5 | ||
|
|
a7348052ae | ||
|
|
1e5282e27e | ||
|
|
d364529919 | ||
|
|
81ef11d855 | ||
|
|
9e9f0fdac6 | ||
|
|
188bdaa837 | ||
|
|
330995588c | ||
|
|
254e26bb3a | ||
|
|
d508380532 | ||
|
|
fefb9051a4 | ||
|
|
e2215df753 | ||
|
|
e5cbea2e9f | ||
|
|
ea0ca94742 | ||
|
|
e779c72396 | ||
|
|
ffc52f13ce | ||
|
|
8d3db5f42c | ||
|
|
f27d4f623c | ||
|
|
57e7db5c2a | ||
|
|
545e569ad6 | ||
|
|
84fe29688c | ||
|
|
69aac32278 | ||
|
|
0a6e0309e5 | ||
|
|
36cf77dc44 | ||
|
|
12eb73aee9 | ||
|
|
1a386c272d | ||
|
|
8e89b39ad3 | ||
|
|
c6963fdf30 | ||
|
|
d9fa6d3f55 | ||
|
|
23ecddc721 | ||
|
|
79825af28e | ||
|
|
db3ca34fcb | ||
|
|
34215fbd32 |
@@ -0,0 +1,17 @@
|
||||
## What
|
||||
|
||||
<!-- What does this PR do, and why? Link the issue it addresses. -->
|
||||
|
||||
## feedpak surface
|
||||
|
||||
<!-- The feedpak spec is sacrosanct: the spec defines the format, this app implements it.
|
||||
Delete this section ONLY if your change doesn't touch how the app reads or writes packs. -->
|
||||
|
||||
- [ ] This PR does **not** change how the app reads/writes feedpaks (manifest keys, pack files, folder layout)
|
||||
- [ ] …or it does, and the spec change landed first via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) — FEP / spec PR: `got-feedback/feedpak-spec#___` (once it merges, re-run this PR's checks and the gate goes green)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `CHANGELOG.md` `[Unreleased]` updated (user-visible changes)
|
||||
- [ ] Tests added/updated for new behaviour
|
||||
- [ ] Commits are DCO signed off (`git commit -s`)
|
||||
@@ -63,11 +63,18 @@ jobs:
|
||||
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: Rebuild Tailwind CSS
|
||||
run: bash scripts/build-tailwind.sh
|
||||
|
||||
@@ -124,6 +131,94 @@ jobs:
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
feedpak-spec:
|
||||
# Guard that core stays faithful to the feedpak format spec, which lives in
|
||||
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
|
||||
# packers and players build against. Four surface checks: core reads/writes
|
||||
# only manifest keys the spec declares (and the scanned-module list can't
|
||||
# fall behind); the exception allowlist never grows, so the FEP process is
|
||||
# the only way a new key lands; core ingests the spec's example packs; packs
|
||||
# committed here pass the spec's reference validator. Motivated by
|
||||
# #933, where a manifest key (`original_audio`) shipped in core without ever
|
||||
# reaching the spec.
|
||||
#
|
||||
# The gate checks against the spec repo's HEAD, deliberately: the app must
|
||||
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
|
||||
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
|
||||
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
|
||||
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
|
||||
# reddens every PR here until core conforms — which is the correct
|
||||
# org-wide signal that the app is out of conformance. The normal FEP is
|
||||
# additive and can never redden this job.
|
||||
name: feedpak-spec
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# This job runs repository code (tools/check_spec_conformance.py) and
|
||||
# never pushes; don't leave the token in git config for it.
|
||||
# fetch-depth: 0 so the base branch is available — the gate must prove
|
||||
# the exception allowlist didn't grow in this PR.
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Check out feedpak-spec at HEAD
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: got-feedback/feedpak-spec
|
||||
ref: main
|
||||
path: .feedpak-spec
|
||||
persist-credentials: false
|
||||
|
||||
- name: Record the spec commit this run verified against
|
||||
# HEAD-tracking means CI results can differ across time on the same
|
||||
# commit. Log the exact spec SHA so a red run is reproducible.
|
||||
run: git -C .feedpak-spec rev-parse HEAD
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
# CI-only: the spec's reference validator needs jsonschema. Not a
|
||||
# runtime dependency — this gate never runs on the serve/Docker path
|
||||
# (constitution Principle I). Pinned for the same reason the spec SHA
|
||||
# is: an upstream release must not turn this job red on a PR that
|
||||
# changed neither this repo nor the spec.
|
||||
pip install 'jsonschema==4.26.0'
|
||||
|
||||
- name: Fetch the base branch's exception allowlist
|
||||
id: baseline
|
||||
run: |
|
||||
# The allowlist is closed: it grandfathers keys that predate this gate
|
||||
# and may only shrink. Prove that by diffing against the base branch —
|
||||
# without this, anyone could append an entry and route around the FEP
|
||||
# process from inside this repo.
|
||||
#
|
||||
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
|
||||
# this workflow for PRs into release/** and for pushes to release/**,
|
||||
# where a main baseline would diff against the wrong branch.
|
||||
# PR -> the branch it merges into
|
||||
# push -> the branch itself (its tip already contains the change, so
|
||||
# this is a no-op; enforcement happens at PR time)
|
||||
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
|
||||
echo "diffing the allowlist against origin/$BASE"
|
||||
git fetch --no-tags --depth=1 origin "$BASE"
|
||||
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
|
||||
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
|
||||
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Only true until the PR that introduces this gate lands.
|
||||
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check feedpak spec conformance
|
||||
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Content packs
|
||||
|
||||
# Build & publish opt-in career venue packs as per-pack, versioned, immutable
|
||||
# releases (convention: tag `venue-<id>-v<N>`, asset `<id>-pack-v<N>.zip`),
|
||||
# then open a PR bumping venues.json url/sha256/bytes. This is automation so
|
||||
# publishing packs is never a person's manual job (SLIM-NIGHTLY item 1b).
|
||||
#
|
||||
# Immutable tags → publishing is a deliberate, versioned act, so this runs on
|
||||
# manual dispatch (not push): a media change means a new version, a human call.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
venues:
|
||||
description: "Space-separated venue ids to (re)publish, e.g. 'club arena'"
|
||||
required: true
|
||||
default: "club"
|
||||
version:
|
||||
description: "Pack version N (tag venue-<id>-vN). Bump for new media."
|
||||
required: true
|
||||
default: "1"
|
||||
|
||||
concurrency:
|
||||
group: content-packs
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Media lives in-tree today; add `lfs: true` once SLIM-NIGHTLY item 4
|
||||
# moves venue-packs/** to Git LFS.
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build & publish packs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Never interpolate dispatch inputs straight into the shell — a crafted
|
||||
# value would execute on the runner with this job's write token. Pass
|
||||
# via env, validate the formats, and use a Bash argument array.
|
||||
VENUES: ${{ github.event.inputs.venues }}
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^[1-9][0-9]*$ ]] || { echo "::error::version must be a positive integer"; exit 1; }
|
||||
[[ "$VENUES" =~ ^[a-z0-9][a-z0-9-]*(\ [a-z0-9][a-z0-9-]*)*$ ]] || { echo "::error::venues must be space-separated venue ids"; exit 1; }
|
||||
read -r -a venues <<< "$VENUES"
|
||||
dirs=()
|
||||
for v in "${venues[@]}"; do
|
||||
dirs+=("plugins/career/venue-packs/$v")
|
||||
done
|
||||
python tools/content_packs.py "${dirs[@]}" \
|
||||
--version "$VERSION" \
|
||||
--publish \
|
||||
--manifest /tmp/packs-manifest.json
|
||||
cat /tmp/packs-manifest.json
|
||||
|
||||
- name: Apply url/sha256/bytes to venues.json
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json, pathlib
|
||||
manifest = json.load(open("/tmp/packs-manifest.json"))
|
||||
vpath = pathlib.Path("plugins/career/venues.json")
|
||||
data = json.loads(vpath.read_text())
|
||||
for v in data["venues"]:
|
||||
m = manifest.get(v["id"])
|
||||
if m and v.get("pack"):
|
||||
v["pack"].update(url=m["url"], sha256=m["sha256"], bytes=m["bytes"])
|
||||
vpath.write_text(json.dumps(data, indent=4) + "\n")
|
||||
PY
|
||||
|
||||
- name: Open manifest-bump PR
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
commit-message: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
|
||||
title: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
|
||||
body: |
|
||||
Automated by the content-packs workflow after publishing
|
||||
`${{ github.event.inputs.venues }}` v${{ github.event.inputs.version }}
|
||||
to their `venue-<id>-v${{ github.event.inputs.version }}` releases.
|
||||
Bumps `venues.json` pack url/sha256/bytes to match the uploaded zips.
|
||||
branch: content-packs/manifest-bump
|
||||
delete-branch: true
|
||||
@@ -24,6 +24,9 @@ plugins/*/
|
||||
!plugins/achievements/
|
||||
!plugins/achievements/**
|
||||
plugins/achievements/__pycache__/
|
||||
!plugins/career/
|
||||
!plugins/career/**
|
||||
plugins/career/__pycache__/
|
||||
!plugins/highway_3d/
|
||||
!plugins/highway_3d/**
|
||||
plugins/highway_3d/__pycache__/
|
||||
|
||||
+335
@@ -7,6 +7,292 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a
|
||||
MIDI part should sound like by binding a rig; core now reads that binding and
|
||||
hands it to the client instead of dropping it. Three parts: the
|
||||
`tone_changes` WS message carries the pack's rig bindings (`base_rig`, and
|
||||
`rig` per change) alongside the tone names it already sent; the manifest
|
||||
`rigs:` key loads the pack's rig library (`rigs.json`, spec §7.9) verbatim;
|
||||
and the binding precedence is resolved per spec §5.1/§5.2 — a manifest
|
||||
arrangement entry's `tones` replaces the arrangement JSON's **wholesale**
|
||||
(no field-level merge), while top-level `drum_tones` binds the primary drum
|
||||
part as the fallback a `type: drums` entry's own `tones` outranks. Core
|
||||
deliberately stops there: it does not select a realization or apply the
|
||||
`intent.gm` floor, which belong to whatever actually voices the part. Packs
|
||||
that bind no rig produce a byte-identical `tone_changes` payload, so existing
|
||||
consumers are unaffected.
|
||||
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
|
||||
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
|
||||
from its release when you reach the venue (sha256-verified), keeping the
|
||||
starter `bar` venue bundled for offline play. Trims ~678 MB from the desktop
|
||||
download; an unpublished pack shows "coming soon" and plays on the standard
|
||||
stage until its release lands.
|
||||
- **Session-sync relay WebSocket — `/ws/sync/{session_id}` (#1030).** A
|
||||
deliberately dumb JSON fan-out room: a text frame received from one client is
|
||||
forwarded verbatim to every other client on the same session id; the server
|
||||
interprets nothing (message schemas are owned by consumers). Rooms are created
|
||||
on first join and garbage-collected when the last socket leaves — no history,
|
||||
no replay, no persistence, so a host that crashes and rejoins the same id
|
||||
resumes publishing to reconnecting subscribers with no server-side
|
||||
coordination. Session ids are client-generated (`[A-Za-z0-9_-]{4,64}`); DoS
|
||||
hygiene for a LAN-exposed port via frame-size (16 KB), per-room (16 sockets),
|
||||
total-room (32), and per-socket rate (120 msg/s sustained, 240 burst) caps —
|
||||
over-limit sockets are closed with a policy code and the room carries on, and
|
||||
a peer that dies — or stalls: fan-out sends are bounded by a 5 s timeout —
|
||||
mid-fan-out is dropped without disturbing delivery to the rest. `main.py`
|
||||
also caps inbound WS frames at the transport (`ws_max_size=64 KB`, down from
|
||||
uvicorn's 16 MB default) so oversized frames never materialize server-side. First consumer: splitscreen's "pop out to LAN" follower mode
|
||||
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
|
||||
frames from a host window to view-only followers on other LAN devices.
|
||||
Implementation in `lib/routers/ws_sync.py`; tests in `tests/test_ws_sync.py`.
|
||||
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
|
||||
carries several drum charts, a **Drum part** selector appears beside the
|
||||
arrangement switcher (advanced settings) so a player can choose which drummer
|
||||
to play. Selecting one re-streams that part's tab over the highway WS
|
||||
(`?drum_part=<id>`, mirroring the arrangement switch); the choice persists
|
||||
across an arrangement change, and the picker reflects the server's
|
||||
authoritative part (unknown/absent selection falls back to the primary). The
|
||||
row hides for single-drum and non-drum songs, so nothing changes there. Builds
|
||||
on the loader below; no plugin change needed — the drum renderer just draws
|
||||
whatever tab streams.
|
||||
- **Multiple drum parts (feedpak 1.17.0 "drums as arrangements").** The sloppak
|
||||
loader now reads `type: drums` arrangement entries carrying per-arrangement
|
||||
`drum_tab` file pointers — a song can ship several drum charts (a second
|
||||
drummer, an aux-percussion layer). Parts surface as `LoadedSloppak.drum_parts`
|
||||
(primary first; the entry aliasing the song-level `drum_tab:` key is the
|
||||
primary and is never loaded twice), the highway WS `song_info` gains a
|
||||
`drum_parts` name list, and `?drum_part=<id>` on the WS URL selects which
|
||||
part's tab streams (`drum_tab` messages carry `part_id` when multiple parts
|
||||
exist; unknown ids fall back to the primary). Pointer entries are **never**
|
||||
loaded as fretted arrangements — the loader's file/notation gate keeps a drum
|
||||
part out of the fretted pipeline (and out of note-detection grading), pinned
|
||||
by test. Legacy single-drum packs read exactly as before, as a one-part list.
|
||||
- **`chart-transform` capability domain (#952)** — plugins can now remap the
|
||||
chart before rendering and scoring through a core-owned provider
|
||||
coordinator. Synchronous transforms run after difficulty filtering; host
|
||||
data is isolated from providers, accepted timelines are time-sorted, and
|
||||
failures fall back to the original chart with a fixed public reason.
|
||||
Effective chart arrays and metadata are available to 2D/custom renderers
|
||||
and highway getters, while `getSongInfo()` retains the original metadata.
|
||||
Provider selection persists and applies to primary and splitscreen highways.
|
||||
- **Library filter: one-click "Not split" + a piano stem pill.** The v3 Filters drawer's
|
||||
stems section gains a **Not split** shortcut that selects "lacks every instrument stem"
|
||||
in one tap — the same query Stem Splitter's missing-stems view runs — instead of
|
||||
cycling five pills to ✕ by hand. The pill row also gains `piano` (the drawer offered
|
||||
five of the canonical six stems, so a piano-only song wrongly matched a hand-built
|
||||
"lacks all" filter). "No lyrics" already existed in the Lyrics section.
|
||||
- **Gold tier (career passports)** — an earned badge turns **gold** when
|
||||
Virtuoso verifies an improvised jam in the passport's style (the
|
||||
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
|
||||
family's style, gained-only, and gold never substitutes for the badge bar
|
||||
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
|
||||
the shelf cover, profile wall, and passport card; the bronze page's "Gold
|
||||
rung coming" preview becomes a live invitation to jam it.
|
||||
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
|
||||
a gig poster proposes the setlist (re-roll for a different bill; save or
|
||||
copy the poster as a PNG), "Play the gig" hands the set to the play queue
|
||||
with the venue on stage, a floating strip tracks the set, and finishing it
|
||||
logs dated entries with per-song accuracies in the passport book — with an
|
||||
encore celebration (crowd eruption + confetti) when the whole set clears
|
||||
the bar, and a summary poster to share. Quitting mid-set simply abandons
|
||||
it: no log, no fail state.
|
||||
- **Career on the Profile and Home pages** — the Profile gains a passport
|
||||
wall (earned-badge covers per instrument, hours, gig count; absent until a
|
||||
passport exists), injected through the same mount-point + rendered-event
|
||||
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
|
||||
The home page's plugin-count stat tile becomes a career trading card
|
||||
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
|
||||
built-in fallback when career has no state. Earned passports gain **Save
|
||||
card / Copy card** — a natively-drawn PNG passport card, downloadable or
|
||||
copied straight to the clipboard for pasting outside the app (shared
|
||||
`blob-io` helpers replace the download idiom previously duplicated in
|
||||
settings-io and diagnostics-export).
|
||||
- **Gigs (backend)** — career mode gains its verb: `POST
|
||||
/api/plugins/career/gigs/propose` builds a playable setlist for an
|
||||
instrument+genre (your qualifying songs plus a couple of stakes songs near
|
||||
the bar; a young passport fills from unplayed genre songs — the first gig
|
||||
is how stubs start; re-roll by calling again), naming the room your stars
|
||||
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
|
||||
from the set's own freshly-recorded stats, an encore flag at the
|
||||
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
|
||||
log (no fail state: the gig you finished is the gig you played). Passports
|
||||
carry their gig log; instruments their gig count.
|
||||
- **3D Highway: fret wires flash on a confirmed hit** — when a scorer (note_detect)
|
||||
confirms a note through the `getNoteState` provider (feedBack#254), the fret wires
|
||||
bracketing it light up. A fretted note lights the wire behind it and the wire it's
|
||||
pressed against; a chord lights only the outermost wires of its shape; an open
|
||||
string lights the anchor lane's edge wires (its gem is drawn as a slab spanning the
|
||||
lane, so those are the wires it sits between); a chord likewise lights the lane's edge
|
||||
wires — the lit lane strip can run a fret past the chord's outermost fret, and a
|
||||
bracket one wire inside the lit lane reads as misaligned (the shape's own outer pair
|
||||
survives only as the fallback on anchor-less charts). At most **two wires are ever lit at
|
||||
once**: when overlapping decay tails (fast passages) would light a run of wires, the
|
||||
flash collapses to the outermost pair of the lit span — one bracket, never a picket
|
||||
fence. The gem's rim joins in: on a confirmed hit the outline flashes in the
|
||||
string's own colour with the same intensity treatment as the wires, fading with the
|
||||
scorer's alpha. With no scorer attached, nothing changes.
|
||||
- **Background controls in the player (3D Highway)** — change the highway
|
||||
background mid-song from the player's Plugin Controls popover instead of
|
||||
opening Settings: a style dropdown, a Reactive toggle, and an Intensity
|
||||
slider, all kept in sync with the Settings page. Controls that the active
|
||||
style ignores are greyed out with a reason on hover (Custom video and
|
||||
Butterchurn use neither; Custom image uses Intensity but not Reactive), so
|
||||
a knob is never present-but-inert. The control disappears when a non-3D
|
||||
renderer is selected. The whole group also greys out while the Venue scene
|
||||
override is active, since none of the three controls reach a mounted style
|
||||
in that mode.
|
||||
|
||||
### Changed
|
||||
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
|
||||
list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS
|
||||
`ready` sends. The stems plugin could only learn it from that WS message, which
|
||||
arrives once the highway is already on screen — so it decoded and then copied the
|
||||
whole song's PCM to its audio worklet with the player visible: over half a gigabyte
|
||||
of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the
|
||||
song-credits card appeared. With the list available at `song:loading` the plugin
|
||||
does all of it before the highway is drawn. Built by calling `load_song` itself, so
|
||||
it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay
|
||||
nothing.
|
||||
- **Folder library renders only the songs on screen** (#965) — a song list used to
|
||||
render *every* song it held. On a flat 50,944-song library that was one `<div>`
|
||||
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
|
||||
built even while another screen was showing. A document that size also punishes
|
||||
unrelated code: any `document.querySelector` that misses has to walk the whole
|
||||
tree — which is how the song-preview menu check ended up eating ~50% of the
|
||||
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
|
||||
windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged.
|
||||
- **3D Highway: fret wires read as a focus cue** — the contrast between the active
|
||||
anchor lane and the rest of the neck is widened (the lane's wires brighter, the rest
|
||||
dimmer), and the wires themselves are slightly thicker.
|
||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
|
||||
from now.
|
||||
|
||||
`full` is a mixdown, not a layer — it already contains every instrument — so
|
||||
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
|
||||
Nothing that sums stems or renders one fader per stem can see it, which is what
|
||||
makes retaining it safe; leaving it in the list would double the whole song and
|
||||
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
|
||||
packer invented the key instead of putting the mixdown where the format says it
|
||||
goes — the bug was in the reader, and this fixes the reader.
|
||||
|
||||
Consequences worth knowing:
|
||||
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
|
||||
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
|
||||
(same values) for one release so a client built against the old frame keeps
|
||||
working; they go with the fallback below (#945).
|
||||
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
|
||||
describe *instrument* stems only — a separated pack that retains its mixdown no
|
||||
longer advertises a bogus "full" stem chip or an inflated stem count.
|
||||
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
|
||||
way, which **widens** its coverage: it previously returned `None` for any pack
|
||||
without the invented key, so fingerprinting silently did nothing for the
|
||||
overwhelming majority of packs.
|
||||
- Core still **reads** `original_audio:` as a deprecated fallback, because every
|
||||
pack written before the spec caught up carries it and would otherwise lose its
|
||||
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
|
||||
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
|
||||
at `default: off`, drops the key); the fallback and the aliases are removed once
|
||||
they are migrated (#945).
|
||||
|
||||
### Added
|
||||
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
||||
resolves override → pack genre → the enrichment match's primary genre
|
||||
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
|
||||
which starved the library genre facet and career passports on real
|
||||
libraries; with the fallback, every enriched song's genre is browsable and
|
||||
passport-able immediately, and coverage grows as enrichment runs.
|
||||
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
||||
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
||||
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
||||
a no-op without a venue pack) and a full-screen overlay drops the bronze
|
||||
stamp with a shine sweep and a confetti burst over whatever screen is active
|
||||
(badges land right after `stats:recorded`, while the player is still up).
|
||||
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
|
||||
chime + notification only. The stamp still slams into the passport book on
|
||||
next open, unchanged.
|
||||
- **Hours-per-genre odometer (career passports)** — the app now measures real
|
||||
play time: the stats recorder accrues **wall-clock** seconds across
|
||||
play/resume ↔ pause/stop/end spans (wall time, not song position — position
|
||||
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
|
||||
against suspend/sleep inflation) and piggybacks them as `seconds` on the
|
||||
`POST /api/stats` calls it already makes. New additive
|
||||
`song_stats.seconds_total` column; a seconds-only POST banks time for
|
||||
unscored plays that run to the song's natural end without touching the
|
||||
resume position (and still counts as playing today for the streak).
|
||||
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
|
||||
shelf cover — a true fact that only grows, never a target or a meter.
|
||||
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
|
||||
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
|
||||
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
|
||||
per genre, data-driven in `passports.json` with display labels). Drill
|
||||
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
|
||||
list still means guitar), so a keys passport never demands a guitar drill.
|
||||
A drill counts as cleared on the first real completion artifact — a
|
||||
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
||||
mastery — rather than only the maxed-speed depth flips. Genres without a
|
||||
curated drill stay songs-only.
|
||||
- **Career passport visuals pack** — earned covers and badge stamps become
|
||||
trading cards (pointer-tracked tilt + light glint, hover-capable devices
|
||||
only); the ghost stamp visibly "carves in" as qualifying songs land (a
|
||||
conic ink fill, no numbers added); the Gold rung preview is a small foil
|
||||
chip with a shimmer sweep, still honestly labeled coming. All theatrics
|
||||
disabled under `prefers-reduced-motion`.
|
||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||
effective genre — Bronze = N genre songs at K★, data-driven in
|
||||
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
|
||||
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
|
||||
(instrument commitment), `POST /passports/open` (open a genre
|
||||
passport), and `POST /drill-state` (intake for the relayed Virtuoso
|
||||
`virtuoso.progress` snapshot, so drill requirements can gate badges
|
||||
server-side). Badges are never stored; the only persisted state (commitments,
|
||||
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
|
||||
settings export/import bundle via `settings.server_files`. Instruments are
|
||||
attributed via the existing progression arrangement→instrument mapping;
|
||||
non-graded instruments (bass, drums) render shown-not-judged — repertoire
|
||||
without a pass bar, never a false badge denial.
|
||||
- **Career passports (UI)** — the Career screen gains a Passports tab beside
|
||||
Venues: a physical per-instrument passport book (embossed leather cover, 3D
|
||||
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
|
||||
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
|
||||
collected ticket stubs, and unopened genres as an "Explore next"
|
||||
travel-brochure rack (invitations, never greyed-out slots or completion
|
||||
meters). Badge earns chime + notify immediately; the stamp slam plays when
|
||||
the passport is next opened. Four small synthesized sound effects ship as
|
||||
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
|
||||
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
|
||||
events (debounced, plus a one-time bootstrap), closing the
|
||||
fires-into-a-void seam without touching the virtuoso plugin.
|
||||
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
|
||||
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
|
||||
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
|
||||
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
|
||||
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
|
||||
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
|
||||
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
|
||||
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
|
||||
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
|
||||
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
|
||||
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
|
||||
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
|
||||
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
|
||||
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
|
||||
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
|
||||
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
|
||||
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
|
||||
re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
|
||||
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
|
||||
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
|
||||
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
|
||||
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
|
||||
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
|
||||
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
|
||||
|
||||
### Removed
|
||||
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
||||
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
||||
@@ -27,6 +313,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **Count-in follows the song's meter and its pickup measure.** The count-in
|
||||
(loop wrap, section practice, and the "Countdown before song" setting) always
|
||||
clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song
|
||||
opening with a pickup (anacrusis) had the pickup enter where the downbeat
|
||||
belonged — putting the player a beat ahead for the whole song. The bar length
|
||||
now comes from the `song_timeline` beats already on the highway
|
||||
(`measure >= 0` marks downbeats; no new plumbing, since the `time_signatures`
|
||||
map is streamed to plugins rather than stored in the frontend), and a first
|
||||
bar shorter than that meter shortens the count by its length: a 1-beat pickup
|
||||
in 4/4 counts "1 2 3" and the music enters on 4. Songs without beats — pre-chart,
|
||||
minigames, synthetic highways — still get four.
|
||||
- **GP8 asset resolution honours the directory the registry named.**
|
||||
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
|
||||
same recording can win (an `.ogg` beside the declared `.mp3` is copied out
|
||||
losslessly instead of transcoded) — but the search was not restricted to the
|
||||
declared directory, so an unrelated file elsewhere in the archive that merely
|
||||
shared the stem could stand in for the declared asset. That is the exact
|
||||
substitution the registry lookup exists to prevent. Candidates are now
|
||||
confined to the registry path's own directory; a genuinely absent asset
|
||||
falls through as documented.
|
||||
- **Guitar Pro 8: the right backing track is extracted when a file carries more than one.**
|
||||
`BackingTrack/AssetId` is a key into the GPIF's `<Assets>` registry — `<Asset
|
||||
id="0"><EmbeddedFilePath>` names the exact path inside the archive — but it
|
||||
was being matched against embedded *filename stems*. GP8 names embedded audio
|
||||
by hash while ids are small integers, so that match essentially never hit: every
|
||||
such file warned and fell through to "first audio asset". That was silently
|
||||
correct while a file carried exactly one recording — with two, a backing track
|
||||
declaring id 1 resolved to asset 0, i.e. the wrong take. Resolution now reads
|
||||
the registry first (verifying the path is really in the archive, so a stale
|
||||
entry falls through rather than resolving to nothing), then the legacy stem
|
||||
match, then the first asset.
|
||||
- **Guitar Pro import no longer fails on non-ASCII song metadata (Windows).**
|
||||
The GP→arrangement-XML writers wrote their output with `Path.write_text()`
|
||||
and no explicit encoding, so on Windows (cp1252 default) a metadata
|
||||
character like the © in an album name ("Chrysalis©1982") was written as a
|
||||
lone `0xA9` byte — invalid UTF-8 — and import died with
|
||||
`not well-formed (invalid token): line N, column 22`. All three arrangement
|
||||
XML writes now pin `encoding="utf-8"`.
|
||||
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
|
||||
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
|
||||
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
|
||||
frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it.
|
||||
The floor geometry now ends at the hit line; its far edge is unchanged, still
|
||||
`-AHEAD*TS` at the note horizon.
|
||||
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
|
||||
`"null"`) can no longer throw on every passport refresh.
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||
|
||||
@@ -400,6 +400,14 @@ highway.setNoteStateProvider((note, chartTime) => {
|
||||
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
|
||||
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
|
||||
|
||||
#### 4. Chart-transform provider — remap the chart before rendering AND scoring (feedBack#952)
|
||||
|
||||
The core-owned `chart-transform` provider coordinator applies synchronous chart substitutions after difficulty filtering. Register and select providers through the capability domain; it owns persistence, refresh, splitscreen propagation, failure attribution, and diagnostics.
|
||||
|
||||
Provider inputs and staged outputs are isolated copies. Async returns or provider errors fail back to the original chart and expose only a fixed public failure reason. `getSongInfo()` remains the original chart contract; transform-aware consumers use the renderer bundle or `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
|
||||
|
||||
See [docs/capability-recipes.md](docs/capability-recipes.md#chart-transform-provider) for the manifest and registration example.
|
||||
|
||||
### Audio mixer fader registration (feedBack#87)
|
||||
|
||||
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
|
||||
@@ -465,6 +473,40 @@ window.feedBack.diagnostics.contribute('my_plugin', {
|
||||
|
||||
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
||||
|
||||
### Detachable panes — pop your panel out into its own window
|
||||
|
||||
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
|
||||
|
||||
```js
|
||||
feedBack.panes.register({
|
||||
id: 'camera_director',
|
||||
title: 'Camera Director',
|
||||
icon: '🎥',
|
||||
element: () => panelEl, // your existing panel, exactly as it is
|
||||
});
|
||||
feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
```
|
||||
|
||||
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
|
||||
|
||||
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
|
||||
|
||||
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
|
||||
|
||||
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
|
||||
|
||||
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
|
||||
|
||||
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
|
||||
|
||||
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
|
||||
|
||||
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
|
||||
|
||||
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
|
||||
|
||||
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
|
||||
@@ -554,6 +596,21 @@ tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still
|
||||
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
|
||||
a local pointer + code map.
|
||||
|
||||
**The spec is sacrosanct — read it BEFORE changing how this app reads or writes packs.** The
|
||||
spec repo defines the format; this app merely implements it ("a change is not part of the format
|
||||
until it lands here" — feedpak-spec/GOVERNANCE.md). Any new manifest key, file, or directory the
|
||||
app touches must land in the spec **first**, via the
|
||||
[FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) (proposal
|
||||
issue → one spec PR updating spec + schemas + example + changelog → then re-run your PR's checks
|
||||
here; the gate verifies against the spec's HEAD, so it goes green the moment your key is real).
|
||||
CI enforces this: the `feedpak-spec` job
|
||||
([docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md)) fails any PR whose code touches a
|
||||
manifest key the spec doesn't declare, and there is **no in-repo bypass** — the exceptions
|
||||
file is a closed grandfather list that only shrinks. If the format seems to be missing something
|
||||
you need, that's a FEP conversation, not a workaround. (Cautionary tale: `original_audio`, #933 —
|
||||
shipped without a spec entry, and third-party packers reverse-engineered a folder convention out
|
||||
of a code comment.)
|
||||
|
||||
**Key code:**
|
||||
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
|
||||
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
|
||||
@@ -633,7 +690,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
|
||||
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
|
||||
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes |
|
||||
| `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
|
||||
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found |
|
||||
| `tone_changes` | `{ type: 'tone_changes', base, base_rig?, data: [{ t, name, rig? }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found. Note the time key is **`t`**, not `time` (both the sloppak path and the legacy XML path emit `t`). `base_rig` and each entry's `rig` are the pack's **rig bindings** — ids into [`rigs.json`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#79-rigsjson) (feedpak §6.9/§7.9), carried through verbatim and **not** resolved by core: selecting a realization and applying the `intent.gm` floor belong to whatever voices the part. Both are **omitted entirely** when the chart binds no rig, so consumers predating the rig model see the payload they always did. |
|
||||
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
|
||||
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
|
||||
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"id": "vocals",
|
||||
"name": "Vocals",
|
||||
"icon": "vocals",
|
||||
"order": 5,
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"required": 2,
|
||||
"challenges": [
|
||||
{
|
||||
"id": "vocals.l1.first-phrase",
|
||||
"title": "First Phrase",
|
||||
"description": "Finish any vocal song with pitch detection on.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "target": 1 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l1.clean-run",
|
||||
"title": "Clean Run",
|
||||
"description": "Score 80%+ accuracy on a vocal song.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.8, "target": 1 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l1.daily-grind",
|
||||
"title": "Daily Grind",
|
||||
"description": "Complete 3 daily quests.",
|
||||
"goal": { "type": "quest_completed", "period": "daily", "target": 3 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"required": 2,
|
||||
"challenges": [
|
||||
{
|
||||
"id": "vocals.l2.five-songs",
|
||||
"title": "Warming Up",
|
||||
"description": "Finish 5 vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "target": 5 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l2.sharpshooter",
|
||||
"title": "On Pitch",
|
||||
"description": "Score 90%+ accuracy on a vocal song.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.9, "target": 1 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l2.arcade-debut",
|
||||
"title": "Arcade Debut",
|
||||
"description": "Play 3 FeedBarcade rounds.",
|
||||
"goal": { "type": "minigame_run", "target": 3 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"required": 2,
|
||||
"challenges": [
|
||||
{
|
||||
"id": "vocals.l3.repertoire",
|
||||
"title": "Repertoire",
|
||||
"description": "Finish 10 different vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "distinct": true, "target": 10 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l3.consistent",
|
||||
"title": "Consistent",
|
||||
"description": "Score 85%+ accuracy on 5 vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.85, "target": 5 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l3.weekly-warrior",
|
||||
"title": "Weekly Warrior",
|
||||
"description": "Complete 2 weekly quests.",
|
||||
"goal": { "type": "quest_completed", "period": "weekly", "target": 2 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 4,
|
||||
"required": 2,
|
||||
"challenges": [
|
||||
{
|
||||
"id": "vocals.l4.streak-week",
|
||||
"title": "Seven-Day Streak",
|
||||
"description": "Reach a 7-day play streak.",
|
||||
"goal": { "type": "streak_reached", "days": 7 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l4.precision",
|
||||
"title": "Pitch Perfect",
|
||||
"description": "Score 95%+ accuracy on 3 vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "target": 3 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l4.marathon",
|
||||
"title": "Marathon",
|
||||
"description": "Finish 25 vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "target": 25 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"level": 5,
|
||||
"required": 2,
|
||||
"challenges": [
|
||||
{
|
||||
"id": "vocals.l5.collector",
|
||||
"title": "Collector",
|
||||
"description": "Earn 5,000 lifetime Decibels.",
|
||||
"goal": { "type": "db_earned", "amount": 5000 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l5.virtuoso",
|
||||
"title": "Virtuoso",
|
||||
"description": "Score 95%+ accuracy on 10 different vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "distinct": true, "target": 10 }
|
||||
},
|
||||
{
|
||||
"id": "vocals.l5.dedicated",
|
||||
"title": "Dedicated",
|
||||
"description": "Finish 50 vocal songs.",
|
||||
"goal": { "type": "song_completed", "instrument": "vocals", "target": 50 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -153,6 +153,14 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
|
||||
|
||||
Diagnostics live under `feedBack.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
|
||||
|
||||
## Chart-Transform Domain
|
||||
|
||||
The chart-transform slice (#952) is a core-owned provider coordinator implemented by [static/capabilities/chart-transform.js](../static/capabilities/chart-transform.js). Its commands register, select, clear, and refresh providers; `chart.transform` is the provider operation. Selection persists by provider id and applies to the primary highway and announced splitscreen instances.
|
||||
|
||||
The synchronous `highway.setChartTransform` data-plane hook runs at chart ready, mastery changes, and refresh—not per frame. Transforms receive isolated chart data after difficulty filtering and may replace notes, chords, anchors, hand shapes, chord templates, string count, tuning, capo, and cent offset. Outputs are isolated and timeline arrays are time-sorted before the built-in renderer, renderer bundle, or public getters read them. Async returns and other provider failures clear the stage and retain the original chart.
|
||||
|
||||
`getSongInfo()` retains original metadata; effective values are exposed by the renderer bundle and dedicated highway getters. Diagnostics under `feedBack.chart_transform.diagnostics.v1` contain provider selection/install state and a fixed public failure reason, never chart data, song identity, or raw exceptions. The domain has no compatibility shim because no earlier chart-substitution surface exists.
|
||||
|
||||
## MIDI-Input Domain
|
||||
|
||||
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
|
||||
@@ -192,7 +200,7 @@ Core domains include review metadata in diagnostics:
|
||||
- `active`: wired to current FeedBack behavior and expected to work as an integration point.
|
||||
- `diagnostic`: support/inspection-only runtime surfaces.
|
||||
|
||||
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
|
||||
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane, and the chart-transform slice (#952) promotes `chart-transform` as the pre-render/pre-scoring chart substitution coordinator. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
|
||||
|
||||
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`; legacy plugin fields continue to load through their existing app paths. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
|
||||
|
||||
@@ -248,7 +256,7 @@ UI placement and settings contributions are real FeedBack surfaces, but they are
|
||||
|
||||
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.feedBack` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
|
||||
|
||||
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
|
||||
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade. The `chart-transform` domain follows this doctrine: its substitution runs through the synchronous `highway.setChartTransform` hook (staged once per chart change), while the capability surface owns only registration, selection, and diagnostics.
|
||||
|
||||
## First-Party Management Plugins
|
||||
|
||||
@@ -295,6 +303,7 @@ From the `feedBack/` directory:
|
||||
```bash
|
||||
node --check static/app.js
|
||||
node --check static/capabilities.js
|
||||
node --check static/capabilities/chart-transform.js
|
||||
node --check static/diagnostics.js
|
||||
node --check plugins/capability_inspector/screen.js
|
||||
node --test tests/js/*.test.js
|
||||
|
||||
@@ -499,6 +499,57 @@ window.feedBack.on('progression:quest-completed', (e) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Chart-Transform Provider
|
||||
|
||||
Plugins that transpose, simplify, annotate, or otherwise rewrite chart data register as `chart-transform` providers (#952). The effective chart reaches the built-in highway, custom renderers, and highway getters on primary and splitscreen instances.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_transform",
|
||||
"name": "My Transform",
|
||||
"standards": ["capability-pipelines.v1"],
|
||||
"capabilities": {
|
||||
"chart-transform": {
|
||||
"roles": ["provider"],
|
||||
"operations": ["chart.transform"],
|
||||
"mode": "active",
|
||||
"compatibility": "none",
|
||||
"ownership": "multi-provider",
|
||||
"safety": "safe",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const api = window.feedBack.capabilities;
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform',
|
||||
command: 'register-provider',
|
||||
source: 'my_transform',
|
||||
payload: {
|
||||
providerId: 'my_transform',
|
||||
label: 'My Transform',
|
||||
transform(input) {
|
||||
const notes = rewriteNotes(input.notes);
|
||||
const allNotes = input.allNotes === input.notes ? notes : rewriteNotes(input.allNotes);
|
||||
return { notes, allNotes };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await api.dispatch({ capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'my_transform', payload: { providerId: 'my_transform' } });
|
||||
|
||||
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: 'my_transform' });
|
||||
```
|
||||
|
||||
`transform(input)` receives filtered `notes`, `chords`, `anchors`, and `handShapes`, plus full-difficulty `allNotes`/`allChords`, `chordTemplates`, `stringCount`, and `songInfo`. It may synchronously return any subset of those arrays plus `tuning`, `capo`, or `centOffset`; null leaves the chart unchanged. The host isolates provider inputs and outputs, time-sorts accepted timelines, and falls back to the original chart on failure.
|
||||
|
||||
Transforms run at chart ready, mastery recompute, and explicit `refresh`, never per frame. Selection persists by provider id. `getSongInfo()` retains original metadata; effective metadata is available through the renderer bundle and `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
|
||||
|
||||
## Future Expansion Domains
|
||||
|
||||
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
|
||||
|
||||
@@ -60,6 +60,10 @@ The progression slice (spec 010) promotes `progression` as an active exclusive-o
|
||||
|
||||
Deferred follow-up slices: a `contributor` role so plugins ship their own challenge/quest content (drums challenges from a drums-scoring plugin, quest-pool entries from minigame plugins), and drums scoring wiring so `song_completed {instrument: "drums"}` goals become satisfiable.
|
||||
|
||||
## Chart-Transform Control Plane Slice
|
||||
|
||||
The chart-transform slice (#952) is an active provider-coordinator domain. It owns provider lifecycle, persisted selection, refresh, failure attribution, and redaction-safe diagnostics. Its synchronous highway hook applies isolated provider output after difficulty filtering to built-in, custom-renderer, and getter consumers across primary and splitscreen highways. No compatibility shim is needed; per-panel independent selection remains a follow-up.
|
||||
|
||||
## Recommended Next Slices
|
||||
|
||||
The plugin inventory suggests this migration order after the audio graph/session and playback slices:
|
||||
|
||||
@@ -20,7 +20,7 @@ Core domains also have a review scope. **Active contract** domains are wired to
|
||||
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
|
||||
|
||||
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
|
||||
|
||||
| chart-transform | provider-coordinator | safe | inspect, list-providers, register-provider, unregister-provider, select-provider, clear-provider, refresh | chart.transform | Synchronous chart substitution after difficulty filtering (#952). Provider data is isolated, timelines are sorted, and failures retain the original chart with a fixed public reason. Diagnostics contain provider and selection state, never chart data, song identity, or raw exceptions. |
|
||||
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
|
||||
|
||||
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# The feedpak spec-conformance gate
|
||||
|
||||
`tools/check_spec_conformance.py`, run in CI as the `feedpak-spec` job.
|
||||
|
||||
## Why
|
||||
|
||||
feedpak is published as an **open format**: its own repo
|
||||
([got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)), a normative spec, JSON
|
||||
Schemas, and a reference validator. That is a promise to everyone outside this codebase — third-party
|
||||
packers, converters, and players build against the spec, and the spec is meant to be the complete and
|
||||
authoritative description of a pack.
|
||||
|
||||
The moment core reads a manifest key the spec doesn't define, that promise breaks silently:
|
||||
|
||||
- A spec-compliant pack is no longer guaranteed to be a fully-working pack.
|
||||
- The reference validator can't warn authors about a key it has never heard of — it will happily green-light
|
||||
the key, and every misspelling of it.
|
||||
- The format's real definition drifts into our source tree. In the case that motivated this gate
|
||||
([#933](https://github.com/got-feedback/feedback/issues/933)), third-party tooling started emitting an
|
||||
`original/` directory that no code anywhere requires — the convention was reverse-engineered from an
|
||||
example in a *code comment*.
|
||||
|
||||
The rule this gate enforces: **any manifest key core reads _or writes_ must be in the spec before core
|
||||
ships code that depends on it.** Spec first, implementation second. Writes are not exempt — a key core
|
||||
writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data.
|
||||
|
||||
Note that "get it into the spec" is not automatically the right fix for an existing violation — for
|
||||
`original_audio` it isn't. The spec already carries the pre-separation mixdown as a stem
|
||||
(`{id: full, file: stems/full.ogg}`), so that key added a *second, redundant* location for audio to a format
|
||||
that already had one, and the resolution is to remove it rather than bless it. The gate takes no position on
|
||||
which way a violation resolves; it only insists that one of the two happens deliberately, in the open,
|
||||
before the code merges.
|
||||
|
||||
## What it checks
|
||||
|
||||
We can't mechanically prove core *interprets* a key the way the spec means. We can prove four surface
|
||||
properties, and they cover the drift that actually occurs.
|
||||
|
||||
| Layer | Check | Catches |
|
||||
|---|---|---|
|
||||
| 1. key-coverage | Every manifest key core reads **or writes** is declared in the spec's `manifest.schema.json`. | Core growing a key the spec never defined — the #933 class. |
|
||||
| 2. allowlist-closed | `feedpak-spec-exceptions.yml` has not **grown** relative to the base branch. | Someone routing around the FEP process by allowlisting their own new key. |
|
||||
| 3. forward | Core's `load_song()` ingests every example pack the spec ships. | The spec adding or tightening something core ignores or breaks on. |
|
||||
| 4. reverse | Every pack committed to this repo passes the spec's `tools/validate.py`. | Core (or a contributor) committing a pack the spec would reject. |
|
||||
|
||||
Layer 1 works by walking the AST of the modules listed in `READERS` and collecting every literal key touched
|
||||
on a manifest dict (`manifest.get("x")`, `manifest["x"]`, and the wrapped
|
||||
`(load_manifest(p) or {}).get("x")` form used in `lib/enrichment.py`).
|
||||
|
||||
**Reads and writes are both checked, and reported differently.** A key core *writes*
|
||||
(`manifest["x"] = v`, as `lib/songmeta.py` does) is spec surface pointed outward: it puts a key into every
|
||||
pack we emit, so an undeclared one seeds the ecosystem with non-spec data. Subscripts are classified by AST
|
||||
context — `Store` is a write, `Load` is a read — so `manifest["year"] = ...` is not miscounted as a read.
|
||||
|
||||
## When it fails
|
||||
|
||||
You added a manifest key the spec doesn't define. **There is exactly one way forward, and it is not in this
|
||||
repo.**
|
||||
|
||||
Land the key in the spec through the **feedpak Enhancement Proposal (FEP)** process
|
||||
([feedpak-spec/CONTRIBUTING.md](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md)):
|
||||
|
||||
1. **Open a FEP issue** on `got-feedback/feedpak-spec` — the problem, the proposed on-disk shape (manifest
|
||||
key and/or side-file), backward compatibility, and the version bump it implies.
|
||||
2. **Discuss**, until it has a clear shape and rough consensus.
|
||||
3. **Land one PR there** that updates the normative spec (`spec/feedpak-v1.md`), the relevant JSON
|
||||
Schema(s), an example in `examples/` that exercises it, and the changelog — *together*. A PR touching
|
||||
only one of those is incomplete.
|
||||
4. **Back here**, just re-run your PR's checks. The gate verifies against the spec's HEAD, so the moment
|
||||
your key is genuinely part of the format, your PR goes green — nothing to bump, nothing to maintain.
|
||||
|
||||
That's deliberately the only route — no experimental prefix, no self-serve allowlist — and it's usually a
|
||||
quick one for additive keys. The reason it's worth the round-trip: the gate checks the whole repo against
|
||||
the living spec, so if non-conformance ever lands, it shows up as red CI on *every* teammate's open PR, and
|
||||
only the person who introduced it can clear it. Going through the FEP keeps your change clean and keeps
|
||||
everyone else unblocked.
|
||||
|
||||
The spec's own governance says the same thing:
|
||||
|
||||
> This repository defines the format only. Applications that read or write feedpak ... track this spec as a
|
||||
> dependency; they do not drive it. **A change is not part of the format until it lands here.**
|
||||
> — [feedpak-spec/GOVERNANCE.md](https://github.com/got-feedback/feedpak-spec/blob/main/GOVERNANCE.md)
|
||||
|
||||
### `feedpak-spec-exceptions.yml` is a closed grandfather list, not a hatch
|
||||
|
||||
It exists solely because `original_audio` predates the gate. **CI fails any PR that adds an entry** (layer 2
|
||||
diffs it against the base branch), so the list can only ever shrink. Entries are debt, each carries a
|
||||
tracking issue, and each disappears when the underlying key is removed from core. The gate also fails on a
|
||||
*stale* entry — the spec caught up, or core stopped touching the key — so the file cannot quietly become
|
||||
somewhere drift accumulates.
|
||||
|
||||
Deleting an entry does not, by itself, get you past the gate: layer 1 still fails while core reads the key.
|
||||
The entry goes when the **code** goes.
|
||||
|
||||
## Tracking the spec's HEAD
|
||||
|
||||
The gate checks out `feedpak-spec` at **HEAD**, on purpose: the app must conform to the *living* spec, and
|
||||
nobody should have to maintain a pin. The dev flow is fully self-serve — gated PR → FEP → spec merge →
|
||||
re-run checks → green.
|
||||
|
||||
Two properties to know about:
|
||||
|
||||
- **The normal FEP is additive** (a new optional key), which only ever makes the gate *looser* — it cannot
|
||||
redden anyone's PR. Only a **breaking** spec change (removing/renaming a key the app uses, tightening the
|
||||
validator against committed packs) turns PRs red repo-wide — and per the spec's compatibility policy that
|
||||
is a rare, deliberate MAJOR event, exactly when an org-wide "the app is out of conformance" signal is the
|
||||
right outcome. The CI job logs the exact spec SHA each run verified against, so a red run is reproducible.
|
||||
- **CI results can change over time on the same commit** — that is inherent to tracking a living contract,
|
||||
and it is the point: green means "conformant *now*", not "conformant when written".
|
||||
|
||||
## Limitations
|
||||
|
||||
Known, and worth fixing in follow-ups rather than blocking on:
|
||||
|
||||
- **Layer 1's receiver detection is heuristic.** Locals *assigned from* `load_manifest(...)` are discovered
|
||||
flow-aware whatever they're called (chart.py's `m` taught us that), and the inline
|
||||
`(load_manifest(p) or {}).get(...)` form is recognised — but a manifest that arrives as a **function
|
||||
parameter** is only recognised by name (`MANIFEST_VARS`: `manifest`, `mf`). A parameter called something
|
||||
else would slip. The hardening step is to route all manifest access through a single declared
|
||||
`KNOWN_MANIFEST_KEYS` registry in `lib/sloppak.py`; the gate then compares registry against schema exactly
|
||||
instead of inferring.
|
||||
- **Layer 1 covers top-level keys only.** Nested structure (`arrangements[].file`, `.id`, `.notation`) isn't
|
||||
checked. Extending to it means walking the schema's `$ref` subschemas.
|
||||
- **Layer 1 recognises `get`, `setdefault`, subscripts, and the known gap-fill helper** as key access.
|
||||
`update()` and `pop()` aren't used against a feedpak manifest anywhere in the tree, so they're deliberately
|
||||
not special-cased rather than speculatively handled. `readers-complete` reuses the same scanner
|
||||
(`keys_touched()`), so this blind spot is shared, not doubled: a module using only unrecognised access forms
|
||||
would evade both.
|
||||
- **Layer 4 can't catch unknown keys**, because `manifest.schema.json` sets `additionalProperties: true` and
|
||||
the reference validator deliberately "treats unknown keys/files as forward-compatible". Fixing this
|
||||
properly belongs in the spec (tighten the schema, or give the validator a `--strict` mode). Until then,
|
||||
layer 1 is the only thing standing between us and the next `original_audio`.
|
||||
@@ -0,0 +1,354 @@
|
||||
# Detachable panes (`window.feedBack.panes`)
|
||||
|
||||
Pop a panel out of the app into its own OS window, and leave it there: while you
|
||||
play, across song switches, on a second monitor, minimized to the system tray.
|
||||
|
||||
Panes exist because the player's rail popovers are **exclusive** — opening one
|
||||
closes the last. You cannot watch the mixer while riding the camera, and both
|
||||
vanish the moment you want to look at the highway.
|
||||
|
||||
---
|
||||
|
||||
## The whole idea, in one sentence
|
||||
|
||||
**We move the real element.**
|
||||
|
||||
Not a copy of your panel. Not a re-implementation of it in the pop-out window.
|
||||
The actual DOM node. Same-origin windows can adopt each other's nodes, and an
|
||||
adopted node keeps its event listeners and its closures — so your panel goes on
|
||||
running *your* code, against *your* state, in *your* realm. The app's stylesheets
|
||||
are copied into the pane window, so it looks identical too.
|
||||
|
||||
What you popped out is what you get. That is the promise, and it is the reason
|
||||
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
|
||||
your UI to keep in step with the first. Those are all solutions to a problem we
|
||||
simply do not have.
|
||||
|
||||
---
|
||||
|
||||
## Adding a pane to your plugin
|
||||
|
||||
Two lines.
|
||||
|
||||
```js
|
||||
// Guard: the panes API is optional. On a host without it, skip both calls and
|
||||
// your panel behaves exactly as it does today.
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (panes && typeof panes.register === 'function') {
|
||||
panes.register({
|
||||
id: 'camera_director',
|
||||
title: 'Camera Director',
|
||||
icon: '🎥',
|
||||
element: () => panelEl, // your existing panel, as it is
|
||||
});
|
||||
panes.attachChip(panelEl, 'camera_director');
|
||||
}
|
||||
```
|
||||
|
||||
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
|
||||
place, same behaviour in every plugin. Clicking it moves your panel to whichever
|
||||
**host** the router picks — usually a pop-out window, but the dock when a window
|
||||
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
|
||||
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
|
||||
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
|
||||
no show/hide logic.
|
||||
|
||||
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
|
||||
your state — all of it comes along, because none of it moved anywhere except into
|
||||
a different window's document.
|
||||
|
||||
### `element` is a function for a reason
|
||||
|
||||
It is resolved at open time, not at registration. Plugins commonly build their
|
||||
panel lazily on first use, or rebuild it wholesale when something changes (Camera
|
||||
Director rebuilds its panel on every mode change). Asking for it when we need it
|
||||
means we always move the live one.
|
||||
|
||||
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
|
||||
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
|
||||
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
|
||||
|
||||
```js
|
||||
if (chipDetach) chipDetach();
|
||||
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
|
||||
```
|
||||
|
||||
Re-attaching is safe while the pane is popped out: the chip reconciles against the
|
||||
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
|
||||
|
||||
### The two things core changes about your element
|
||||
|
||||
**1. Placement.** `.fb-paned` is added while the pane is out:
|
||||
|
||||
```css
|
||||
position: static; inset: auto; margin: 0; width: 100%;
|
||||
max-width: none; max-height: none; z-index: auto; box-shadow: none;
|
||||
```
|
||||
|
||||
Your panel was almost certainly a fixed overlay pinned to a corner of the app
|
||||
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
|
||||
every one of those is wrong — it would float 72px down from the top of a 380px
|
||||
window, still 288px wide, still casting a shadow over nothing.
|
||||
|
||||
Note there is deliberately **no `display` override**: a panel that is
|
||||
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
|
||||
and your panel's own internal layout are untouched.
|
||||
|
||||
**2. Visibility.** A panel is usually hidden until its launcher is clicked, and a
|
||||
pane can be opened from the tray or the rail without that ever happening — so core
|
||||
un-hides it, in the two ways a panel is actually hidden:
|
||||
|
||||
```js
|
||||
el.hidden = false;
|
||||
if (el.style.display === 'none') el.style.display = '';
|
||||
```
|
||||
|
||||
**Both are restored exactly as they were when the pane docks**, along with the
|
||||
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
|
||||
goes back to being closed; one that was open stays open.
|
||||
|
||||
---
|
||||
|
||||
## Spec
|
||||
|
||||
```js
|
||||
feedBack.panes.register({
|
||||
id, // required, unique
|
||||
element, // required — an Element, or a function returning one
|
||||
title, // shown in the pane window's title bar, the dock card, the tray
|
||||
icon, // one glyph, for the dock/tray/launcher lists
|
||||
width, height, // the pane window's initial size (it remembers yours after that)
|
||||
defaultHost, // 'window' (default) or 'dock'
|
||||
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
|
||||
});
|
||||
```
|
||||
|
||||
```js
|
||||
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
|
||||
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
|
||||
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
|
||||
```
|
||||
|
||||
`attachChip` puts the chip in the `header` element you pass, else in
|
||||
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
|
||||
An explicit `header` always wins.
|
||||
|
||||
---
|
||||
|
||||
## Hosts
|
||||
|
||||
`detach(id)` puts a pane in the best host available:
|
||||
|
||||
| host | | |
|
||||
|---|---|---|
|
||||
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
|
||||
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
|
||||
|
||||
You don't pick; you declare `defaultHost` and the router does the rest.
|
||||
|
||||
In the **desktop app** a pane you left popped out comes back popped out on next
|
||||
launch. In a **browser** it comes back **docked** — a browser blocks
|
||||
`window.open()` without a user gesture, so restoring it would only ever produce a
|
||||
"pop-up blocked" toast. The chip pops it out again on your next click.
|
||||
|
||||
---
|
||||
|
||||
## Best practices
|
||||
|
||||
Every item below is something that has already gone wrong, in this codebase, on
|
||||
this feature. They are cheap to get right up front and confusing to diagnose later
|
||||
— a broken pane usually *looks* perfect.
|
||||
|
||||
### 1. Your code still runs in the main window
|
||||
|
||||
The element is *displayed* in the pane window, but its closures, its timers and its
|
||||
`document` references all still belong to the main realm. **That is precisely why
|
||||
everything keeps working** — and it has one sharp consequence:
|
||||
|
||||
```js
|
||||
// WRONG — lands in the MAIN window, not the pane the user is looking at.
|
||||
document.body.appendChild(myTooltip);
|
||||
|
||||
// RIGHT — anchored to the panel, so it travels with it.
|
||||
panelEl.appendChild(myTooltip);
|
||||
```
|
||||
|
||||
**And every lookup for something inside your panel.** Once the panel has moved,
|
||||
`document.getElementById('my-panel-thing')` returns `null` — so every update it
|
||||
guards silently stops happening, precisely while the user is looking at the panel.
|
||||
No error. Just a UI that quietly goes dead.
|
||||
|
||||
```js
|
||||
// WRONG — null once the panel is popped out.
|
||||
document.getElementById('my-panel-hint').textContent = msg;
|
||||
|
||||
// RIGHT — search FROM the panel; works in either document.
|
||||
panelEl.querySelector('#my-panel-hint').textContent = msg;
|
||||
```
|
||||
|
||||
Elements that live outside your panel (your plugin's *screen*, host chrome) never
|
||||
move, and should keep using `document.getElementById`. Audit which is which — in
|
||||
the stem mixer, four ids were inside the panel and a dozen were not.
|
||||
|
||||
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
|
||||
dismiss listener on `window` watches a window the user isn't clicking in. Use
|
||||
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
|
||||
panel is actually in.
|
||||
|
||||
### 2. Don't hide your panel yourself
|
||||
|
||||
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
|
||||
you are hiding the node that just moved — and the pane window renders nothing.
|
||||
(This is not hypothetical: core's own chip did exactly this, and the first
|
||||
pop-out shipped blank because of it.)
|
||||
|
||||
### 3. Prefer `hidden` or a class for show/hide
|
||||
|
||||
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
|
||||
inline `display: none` if that's how you hide — and **restores both on dock**. So
|
||||
either style works.
|
||||
|
||||
`hidden` is still the better choice: it composes with everything, and it leaves
|
||||
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
|
||||
deliberately does not override `display` for exactly that reason.
|
||||
|
||||
```js
|
||||
panel.hidden = true; // best
|
||||
panel.style.display = 'none'; // works — core saves and restores it
|
||||
```
|
||||
|
||||
### 4. `element` is a function — return the *live* node
|
||||
|
||||
It is resolved when the pane opens, not when you register. Plugins build panels
|
||||
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
|
||||
change). If you rebuild yours, **re-attach the chip**:
|
||||
|
||||
```js
|
||||
if (chipDetach) chipDetach(); // attachChip returns a detach()
|
||||
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
|
||||
```
|
||||
|
||||
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
|
||||
no longer exists.
|
||||
|
||||
### 5. `isConnected` lies about a panel that is a pane
|
||||
|
||||
This one has cost more debugging than everything else on this page combined, and
|
||||
it lies in **both directions**.
|
||||
|
||||
**It says `true` when your panel is not here.** A panel sitting in a pane window is
|
||||
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
|
||||
`true` and then acts on a panel that is somewhere else entirely.
|
||||
|
||||
**It says `false` when your panel is perfectly fine.** The host *detaches* the
|
||||
element the moment a pop-out starts, before the new window has even loaded. In that
|
||||
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
|
||||
**second panel**, while the host is still holding the first.
|
||||
|
||||
That second panel is the one your module variables now point at. The one the user
|
||||
can *see* is the original, owned by nobody. So:
|
||||
|
||||
- its close button closes the *other*, invisible panel — "the X doesn't work"
|
||||
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
|
||||
|
||||
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
|
||||
|
||||
**Ask the pane system, not the DOM.** It knows where your element is:
|
||||
|
||||
```js
|
||||
function paneOwnsPanel() {
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
|
||||
}
|
||||
|
||||
// "Is my panel gone?" — not "is it in this document?"
|
||||
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
|
||||
```
|
||||
|
||||
Every `isConnected` check on a panel that can be a pane needs this. In the stem
|
||||
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
|
||||
fast path (which decided the UI was unmounted and swept on every mutation).
|
||||
|
||||
For "which document is it in right now", use `el.ownerDocument === document`, or
|
||||
take the optional `onHost(hostId, el)` callback, which fires on both moves.
|
||||
|
||||
### 6. If your plugin can be re-injected, it must be able to remove itself
|
||||
|
||||
The host may run your script more than once — a screen re-entry, a version change.
|
||||
Without a teardown, the second run builds a second panel while the first one is
|
||||
still on screen, and every module variable in the new instance points at the new,
|
||||
invisible one. The user clicks the panel they can see; nothing happens.
|
||||
|
||||
Everything stateful duplicates: observers, timers, listeners. And one thing is
|
||||
worse than duplicated — **your pane registration**:
|
||||
|
||||
```js
|
||||
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
|
||||
```
|
||||
|
||||
First registration wins, so a stale one hands the host `panel` from a **dead
|
||||
instance**. Popping out then moves a panel nobody owns.
|
||||
|
||||
So publish a teardown handle and call it at the top of your script:
|
||||
|
||||
```js
|
||||
if (window.__myPluginInstance?.destroy) {
|
||||
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
|
||||
}
|
||||
|
||||
window.__myPluginInstance = {
|
||||
destroy() {
|
||||
observer?.disconnect();
|
||||
clearTimeout(myTimer);
|
||||
chipDetach?.(); // attachChip() returned this
|
||||
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
|
||||
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Belt and braces: when you build your panel, remove any node carrying its id that
|
||||
isn't yours. A zombie panel is worse than no panel — it looks alive and does
|
||||
nothing.
|
||||
|
||||
### 7. Expect rAF to be throttled while your pane has focus
|
||||
|
||||
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
|
||||
main window is exactly what's backgrounded while the user is looking at your pane.
|
||||
Your rAF lives in the main window.
|
||||
|
||||
Event-driven panels (sliders, buttons, presets) don't care. A panel that
|
||||
*animates continuously* may run slowly precisely when it's the only thing on
|
||||
screen. Drive such animation from data you already have, or accept the stutter.
|
||||
|
||||
### 8. Don't synchronise anything
|
||||
|
||||
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
|
||||
UI. There is **one** realm and **one** panel. If you find yourself writing sync
|
||||
code, you have misunderstood the model — the whole point is that there is nothing
|
||||
to sync.
|
||||
|
||||
### 9. Nothing here is required
|
||||
|
||||
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
|
||||
and your panel behaves exactly as it does today. Guard, don't depend:
|
||||
|
||||
```js
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.register !== 'function') return;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Things core guarantees
|
||||
|
||||
- **The element goes home exactly where it came from** — same parent, same position
|
||||
among its siblings. Don't move it yourself while it's popped out.
|
||||
- **It comes home alive.** Core evacuates the element *before* the pane window's
|
||||
document is destroyed. (Get this wrong — dock after the window dies — and the
|
||||
node returns looking perfect with every listener in its subtree silently gone.
|
||||
That bug is why this section exists.)
|
||||
- **A pane window the user closes, or that crashes, is reaped** and the element
|
||||
docked back. Your panel is never stranded in a dead document.
|
||||
- **The app's stylesheets are copied into the pane window**, so your panel looks
|
||||
identical — including your plugin's own `styles` sheet.
|
||||
@@ -189,3 +189,23 @@ out of the capability graph.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
rail 30, popovers 40).
|
||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||
|
||||
## Injecting into core shells (profile, dashboard)
|
||||
|
||||
Core screens that accept plugin sections render **mount points** — usually
|
||||
empty, sometimes holding core's own **fallback content** (the Dashboard's
|
||||
career slot ships the plugin-count stat) — and announce each (re)build with a
|
||||
DOM event, because their `innerHTML` swap wipes anything previously injected.
|
||||
A plugin listens for the event and **replaces the mount's content** (never
|
||||
append — a fallback may be present) by id — the same seam every time:
|
||||
|
||||
| Shell | Event | Mounts |
|
||||
| --- | --- | --- |
|
||||
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
|
||||
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
|
||||
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
|
||||
|
||||
Rules: inject on every event (the mount is fresh), keep the section
|
||||
**absent-not-empty** (no state → leave the mount alone / empty), and guard
|
||||
re-wired listeners with a `dataset` flag when your own refresh path can run
|
||||
against an unwiped mount.
|
||||
|
||||
@@ -61,6 +61,8 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `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
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
|
||||
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
|
||||
`scriptType: module` file when career work next touches it) — 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.
|
||||
|
||||
+18
-1
@@ -55,7 +55,7 @@ module.exports = [
|
||||
// module graph, which is what makes no-cycle meaningful here — a carved
|
||||
// module that imports app.js back would close a cycle and fail this gate.
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'],
|
||||
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js', 'static/highway.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
@@ -67,6 +67,23 @@ module.exports = [
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// highway_3d plugin — screen.js uses ES-module syntax (scriptType:module)
|
||||
// so it needs module sourceType to parse. Add no-use-before-define here
|
||||
// (variables: true, functions: false) to catch const/let TDZ violations
|
||||
// inside factory functions across the whole plugin tree.
|
||||
// Proven to flag the broken-tip regression (fix/h3d-viz-init-fallback):
|
||||
// broken: const sY used at createScoreFx DI before its declaration → ERROR
|
||||
// fixed: sY hoisted above createScoreFx call → clean (0 errors)
|
||||
{
|
||||
files: [
|
||||
'plugins/highway_3d/screen.js',
|
||||
'plugins/highway_3d/src/**/*.js',
|
||||
],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
rules: {
|
||||
'no-use-before-define': ['error', { variables: true, functions: false }],
|
||||
},
|
||||
},
|
||||
// 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,56 @@
|
||||
# CLOSED grandfather list — manifest keys core reads or writes that predate the
|
||||
# spec-conformance gate and that the feedpak spec does not define.
|
||||
#
|
||||
# Please don't add entries here — CI will flag any PR that grows this list, so
|
||||
# it can only shrink over time. That's by design, not distrust: the moment the
|
||||
# app touches a key the spec doesn't define, every teammate's PR starts failing
|
||||
# the conformance gate too, and whoever added the key is the only person who
|
||||
# can fix it. The FEP process below avoids putting anyone in that spot. The
|
||||
# feedpak spec's own governance is explicit:
|
||||
#
|
||||
# "This repository defines the format only. Applications that read or write
|
||||
# feedpak ... track this spec as a dependency; they do not drive it.
|
||||
# A change is not part of the format until it lands here."
|
||||
# — got-feedback/feedpak-spec, GOVERNANCE.md
|
||||
#
|
||||
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
|
||||
# process — see feedpak-spec/CONTRIBUTING.md:
|
||||
#
|
||||
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
|
||||
# on-disk shape, backward compatibility, and the version bump implied.
|
||||
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
|
||||
# example that exercises it, and the changelog — together.
|
||||
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
|
||||
# HEAD, so once your key is in the spec, the gate goes green.
|
||||
#
|
||||
# That's the supported route — and usually a quick one for additive keys. If
|
||||
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
|
||||
# entry here won't (CI rejects it).
|
||||
#
|
||||
# Entries below exist ONLY because they predate the gate. Each is debt with a
|
||||
# tracking issue, and each disappears when its issue is fixed. The gate also
|
||||
# fails if an entry goes stale — the spec caught up, or core no longer reads or
|
||||
# writes the key — so this file cannot quietly become a place drift hides.
|
||||
|
||||
exceptions:
|
||||
- key: original_audio
|
||||
issue: https://github.com/got-feedback/feedback/issues/945
|
||||
reason: >-
|
||||
Added by #583 (the full mix played while every stem fader sits at unity,
|
||||
since demucs recombination is lossy). It never went through a FEP and the
|
||||
spec does not define it — the drift this gate exists to prevent.
|
||||
|
||||
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
|
||||
complete mixdown (feedpak-spec#53), and core now reads the full mix from
|
||||
that stem. Nothing depends on this key any more — not the loader, not
|
||||
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
|
||||
|
||||
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
|
||||
(_legacy_full_mix), kept for one release because every pack produced before
|
||||
the spec caught up carries `original_audio: original/full.ogg` and would
|
||||
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
|
||||
rewrites those packs into the spec shape.
|
||||
|
||||
This entry disappears with that fallback — tracked by #945, which cannot be
|
||||
forgotten: the gate fails if the entry goes stale, and deleting the read is
|
||||
what makes it stale.
|
||||
+44
-6
@@ -288,17 +288,55 @@ def demo_mode_enabled() -> bool:
|
||||
|
||||
|
||||
def start_janitor() -> None:
|
||||
"""Start the hourly session janitor. Called from server.py's startup hook.
|
||||
"""Start the hourly session janitor, at most one at a time. server.py's startup hook.
|
||||
|
||||
NB the caller's guard is the buggy one described in this module's header (issue #902).
|
||||
Behaviour is preserved verbatim: this starts a thread every time it is called.
|
||||
━━━ THE GUARD ASKS "IS A HEALTHY JANITOR RUNNING?", AND NOTHING ELSE ━━━
|
||||
|
||||
Three ways to get this wrong, and #902 plus two Codex passes found all three:
|
||||
|
||||
1. NO GUARD (the original #902 bug). The re-entry check lived at the call site as
|
||||
`A or (B and C)`, so it never ran, and a second startup started a SECOND thread,
|
||||
overwrote the handle, and left the first to fire hooks forever, unjoinable.
|
||||
|
||||
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). stop_janitor() deliberately
|
||||
leaves that flag True when a hook outruns its join timeout — so once that hook
|
||||
finishes and the thread exits, the flag is stale and a later startup would refuse to
|
||||
start a replacement. Demo cleanup silently dead for the rest of the process.
|
||||
|
||||
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). A timed-out stop leaves the
|
||||
old thread ALIVE BUT DOOMED — its stop event is set, and it exits the moment its
|
||||
current hook returns. Treating it as a running janitor means the replacement is never
|
||||
started, and we are back at (2) a second later.
|
||||
|
||||
So a janitor counts as running only if its thread is alive AND it has not been told to
|
||||
stop.
|
||||
|
||||
━━━ AND WHY EACH JANITOR OWNS ITS STOP EVENT ━━━
|
||||
|
||||
This used to `_DEMO_JANITOR_STOP.clear()` a single shared Event. If a replacement were
|
||||
started while a doomed thread was still finishing a hook, clearing the shared event would
|
||||
RESURRECT it — it loops back to `stop.wait()`, sees the flag cleared, and carries on.
|
||||
Two janitors, which is the exact bug we started from.
|
||||
|
||||
A fresh Event per janitor makes that impossible: the old thread waits on its OWN event,
|
||||
which stays set forever, so it can only exit.
|
||||
"""
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
||||
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _DEMO_JANITOR_STOP
|
||||
|
||||
thread = _DEMO_JANITOR_THREAD
|
||||
if thread is not None and thread.is_alive() and not _DEMO_JANITOR_STOP.is_set():
|
||||
return # a healthy janitor is already running
|
||||
|
||||
# Either there is no janitor, or the previous one is dead / dying. Give the new one its
|
||||
# OWN stop event so the old one stays stopped no matter what we do to ours.
|
||||
stop = threading.Event()
|
||||
_DEMO_JANITOR_STOP = stop
|
||||
_DEMO_JANITOR_STARTED = True
|
||||
_DEMO_JANITOR_STOP.clear()
|
||||
|
||||
def _janitor():
|
||||
while not _DEMO_JANITOR_STOP.wait(timeout=3600):
|
||||
# Closes over `stop`, NOT the module global — a later start_janitor() rebinds
|
||||
# _DEMO_JANITOR_STOP, and this thread must keep watching the event it was born with.
|
||||
while not stop.wait(timeout=3600):
|
||||
with _DEMO_JANITOR_HOOKS_LOCK:
|
||||
hooks = list(_DEMO_JANITOR_HOOKS)
|
||||
for hook in hooks:
|
||||
|
||||
+9
-1
@@ -14,6 +14,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
from safepath import resolved_root
|
||||
|
||||
|
||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||
@@ -86,7 +87,14 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
root = dlc.resolve()
|
||||
# The library root is fixed for the life of the process, but this
|
||||
# function runs once per song / art fetch / scanned row — and
|
||||
# `.resolve()` lstats every path component. Re-resolving here was
|
||||
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
|
||||
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
|
||||
# stat is a userspace round trip. Resolve the root once; see
|
||||
# safepath.resolved_root for the caching contract.
|
||||
root = resolved_root(dlc)
|
||||
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
||||
# it never touches the filesystem, so an in-library junction component
|
||||
# is preserved (allowed) while `..`/absolute segments still escape and
|
||||
|
||||
+20
-5
@@ -368,10 +368,12 @@ def _acoustid_gate() -> "JSONResponse | None":
|
||||
|
||||
def _song_audio_file(filename: str) -> "str | None":
|
||||
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
|
||||
fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a
|
||||
loose folder's audio. None when the song can't be found or ships no full-mix
|
||||
audio (some packs carry only stems). Mirrors serve_sloppak_file's containment
|
||||
guards so a crafted filename can't read outside DLC_DIR / the pack."""
|
||||
fingerprinting: a sloppak's complete mixdown, or a loose folder's audio. None
|
||||
when the song can't be found or carries no mixdown (a pack that kept only its
|
||||
separated stems — an acoustic fingerprint of one re-summed from them would not
|
||||
match the recording, so we decline rather than submit a lossy reconstruction).
|
||||
Mirrors serve_sloppak_file's containment guards so a crafted filename can't
|
||||
read outside DLC_DIR / the pack."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return None
|
||||
@@ -383,7 +385,20 @@ def _song_audio_file(filename: str) -> "str | None":
|
||||
canon = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio")
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
# The mixdown is the RESERVED `full` stem (spec §5.3). Unlike playback,
|
||||
# fingerprinting wants it even when it is the pack's ONLY stem — a
|
||||
# single-mix pack is exactly the master audio we want to fingerprint —
|
||||
# so this asks find_full_mix() rather than partition_stems().
|
||||
stems = manifest.get("stems") or []
|
||||
full = sloppak_mod.find_full_mix(
|
||||
[s for s in stems if isinstance(s, dict)]
|
||||
)
|
||||
rel = full.get("file") if full else None
|
||||
# DEPRECATED fallback: packs written before the spec reserved `full` put
|
||||
# the mixdown behind a top-level `original_audio:` key instead (#933).
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
rel = manifest.get("original_audio")
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
return None
|
||||
src = sloppak_mod.get_cached_source_dir(canon)
|
||||
|
||||
+1
-1
@@ -2080,7 +2080,7 @@ def convert_file(
|
||||
safe_name = track.name.strip().replace(" ", "_").replace("/", "_")
|
||||
filename = f"{safe_name}_{arr_name or 'arr'}.xml"
|
||||
filepath = out / filename
|
||||
filepath.write_text(xml_str)
|
||||
filepath.write_text(xml_str, encoding="utf-8")
|
||||
output_files.append(str(filepath))
|
||||
|
||||
return output_files
|
||||
|
||||
+2
-2
@@ -1680,7 +1680,7 @@ def convert_file(
|
||||
filepath = safe_join(out, filename)
|
||||
if filepath is None:
|
||||
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
|
||||
filepath.write_text(xml_str)
|
||||
filepath.write_text(xml_str, encoding="utf-8")
|
||||
output_files.append(str(filepath))
|
||||
continue
|
||||
|
||||
@@ -2108,7 +2108,7 @@ def convert_file(
|
||||
filepath = safe_join(out, filename)
|
||||
if filepath is None:
|
||||
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
|
||||
filepath.write_text(xml_str)
|
||||
filepath.write_text(xml_str, encoding="utf-8")
|
||||
output_files.append(str(filepath))
|
||||
|
||||
# Keys/piano tracks additionally get a standard-notation sidecar
|
||||
|
||||
+76
-6
@@ -72,15 +72,59 @@ def _parse_gpif(data: bytes):
|
||||
return ET.fromstring(data)
|
||||
|
||||
|
||||
def _asset_path_from_registry(root, asset_id: str) -> str | None:
|
||||
"""The ZIP path an ``<Asset id=...>`` declares, or None.
|
||||
|
||||
GPIF shape::
|
||||
|
||||
<Assets>
|
||||
<Asset id="0">
|
||||
<EmbeddedFilePath>Content/Assets/<hash>.mp3</EmbeddedFilePath>
|
||||
|
||||
Separators are normalised (a writer may emit backslashes) and the
|
||||
result is returned as-is for the caller to verify against the
|
||||
archive — this function never decides that a path exists.
|
||||
"""
|
||||
if root is None or not asset_id:
|
||||
return None
|
||||
try:
|
||||
for asset in root.iter('Asset'):
|
||||
if (asset.get('id') or '').strip() != asset_id:
|
||||
continue
|
||||
node = asset.find('EmbeddedFilePath')
|
||||
path = (node.text or '').strip() if node is not None else ''
|
||||
if not path:
|
||||
return None
|
||||
return path.replace('\\', '/').lstrip('./')
|
||||
except Exception:
|
||||
# A malformed registry is not fatal — the caller has two more
|
||||
# resolution steps behind this one.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
|
||||
"""Resolve the embedded backing-track audio asset inside a .gp ZIP.
|
||||
|
||||
Matches ``BackingTrack/AssetId`` against the audio files under
|
||||
``Content/Assets/`` (OGG, MP3, M4A, …) and falls back to the first
|
||||
audio asset when the declared id is missing or unmatched. Returns
|
||||
``(asset_stem, audio_zip_path)``, or ``('', None)`` when the archive
|
||||
has no audio asset. Shared by ``extract_sync`` and ``extract_audio``
|
||||
so the matching logic can't drift between them.
|
||||
``BackingTrack/AssetId`` is a key into the GPIF's ``<Assets>``
|
||||
registry — ``<Asset id="0"><EmbeddedFilePath>`` names the exact path
|
||||
inside the ZIP — NOT a filename stem. Resolution order:
|
||||
|
||||
1. the registry entry for the declared id (authoritative);
|
||||
2. a filename-stem match (files whose stem IS the id);
|
||||
3. the archive's first audio asset.
|
||||
|
||||
Step 2 was previously the only lookup, which mattered because GP8
|
||||
names embedded files by hash while ids are small integers, so the
|
||||
stem match essentially never hit: every such file logged a warning
|
||||
and fell through to step 3. That was silently correct only because a
|
||||
file almost always carries exactly ONE audio asset — with two, a
|
||||
backing track declaring id 1 resolved to asset 0, i.e. the wrong
|
||||
recording.
|
||||
|
||||
Returns ``(asset_stem, audio_zip_path)``, or ``('', None)`` when the
|
||||
archive has no audio asset. Shared by ``extract_sync`` and
|
||||
``extract_audio`` so the matching logic can't drift between them.
|
||||
"""
|
||||
audio_files = [
|
||||
n for n in zf.namelist()
|
||||
@@ -115,6 +159,32 @@ def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
|
||||
declared = (aid.text or '').strip() if aid is not None else ''
|
||||
|
||||
if declared:
|
||||
# 1. The <Assets> registry is authoritative: it maps the id to the
|
||||
# embedded path directly. Membership in the archive is verified
|
||||
# rather than trusted — the path comes out of the file, and a
|
||||
# stale/edited entry must fall through, not resolve to nothing.
|
||||
registry_path = _asset_path_from_registry(root, declared)
|
||||
if registry_path:
|
||||
# Matched on STEM, not the whole path, so a format variant of the
|
||||
# same recording can win (see _prefer_ogg) — but constrained to the
|
||||
# directory the registry actually named. Without that constraint an
|
||||
# unrelated file that merely shares the stem could stand in for the
|
||||
# declared asset, which is the failure the registry lookup exists
|
||||
# to prevent.
|
||||
declared_path = Path(registry_path)
|
||||
same_stem = [
|
||||
n for n in audio_files
|
||||
if Path(n).stem == declared_path.stem
|
||||
and Path(n).parent == declared_path.parent
|
||||
]
|
||||
if same_stem:
|
||||
return declared_path.stem, _prefer_ogg(same_stem)
|
||||
_log.warning(
|
||||
'gp8_audio_sync: AssetId %r maps to %r, which is not an audio '
|
||||
'asset in the archive; falling back',
|
||||
declared, registry_path,
|
||||
)
|
||||
# 2. Legacy shape: files whose stem IS the declared id.
|
||||
matched = [n for n in audio_files if Path(n).stem == declared]
|
||||
if matched:
|
||||
return declared, _prefer_ogg(matched)
|
||||
|
||||
+105
-26
@@ -17,7 +17,12 @@ import threading
|
||||
from typing import ClassVar
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB, _tuning_group_key_sql
|
||||
from metadata_db import (
|
||||
MetadataDB, _effective_tuning_cols_sql, _perspective_is_inferred_sql,
|
||||
_tuning_group_key_sql,
|
||||
)
|
||||
import tunings as tunings_mod
|
||||
from tunings import DEFAULT_PERSPECTIVE, PERSPECTIVES
|
||||
from routers import art as art_router
|
||||
|
||||
import logging
|
||||
@@ -39,9 +44,6 @@ def _safe_art_redirect_url(url: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
||||
|
||||
|
||||
class LocalLibraryProvider:
|
||||
id = "local"
|
||||
label = "My Library"
|
||||
@@ -69,28 +71,43 @@ class LocalLibraryProvider:
|
||||
def query_stats(self, **kwargs) -> dict:
|
||||
return self._db.query_stats(**kwargs)
|
||||
|
||||
def tuning_names(self) -> dict:
|
||||
def tuning_names(self, instrument: str = DEFAULT_PERSPECTIVE) -> dict:
|
||||
# Group custom tunings on their raw offsets so distinct ones stay
|
||||
# distinct (tuning_name collapses them all to "Custom Tuning"); named
|
||||
# tunings keep grouping by name (stable across the rescan boundary, no
|
||||
# offsets/name split). `key` is the value the client sends back as the
|
||||
# filter selector — equal to the name for named tunings, the offsets
|
||||
# string for customs; offsets also feed the client's custom-pill label.
|
||||
#
|
||||
# `instrument=bass` swaps every column for its effective bass-facing
|
||||
# expression (bass arrangement's tuning, guitar fallback) — the SAME
|
||||
# expressions _build_intrinsic_where filters on, so a facet entry
|
||||
# always selects exactly the songs it counted.
|
||||
name_sql, offsets_sql, sort_sql = _effective_tuning_cols_sql("songs", instrument)
|
||||
gkey_sql = _tuning_group_key_sql("songs", instrument)
|
||||
# How many of a row's songs are showing an INFERRED tuning — i.e. have
|
||||
# no bass chart of their own and are falling back to the guitar-derived
|
||||
# one. Reported per entry so the UI can be honest about it instead of
|
||||
# presenting a borrowed tuning as a measured one. Always 0 for guitar.
|
||||
inferred_sql = f"SUM({_perspective_is_inferred_sql('songs', instrument)})"
|
||||
with self._db._lock:
|
||||
rows = self._db.conn.execute(
|
||||
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
|
||||
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
|
||||
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
|
||||
f"SELECT {name_sql}, {gkey_sql} AS gkey, "
|
||||
f"MIN({sort_sql}), COUNT(*), MIN({offsets_sql}), {inferred_sql} "
|
||||
f"FROM songs WHERE title != '' AND COALESCE({name_sql}, '') != '' "
|
||||
"GROUP BY gkey COLLATE NOCASE "
|
||||
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
|
||||
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
|
||||
"tuning_name COLLATE NOCASE"
|
||||
f"ORDER BY ABS(COALESCE(MIN({sort_sql}), 0)), "
|
||||
f"COALESCE(MIN({sort_sql}), 0) ASC, "
|
||||
f"{name_sql} COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return {
|
||||
"instrument": instrument,
|
||||
"tunings": [
|
||||
{"name": name, "key": gkey, "offsets": offs or "",
|
||||
"sort_key": int(sk or 0), "count": count}
|
||||
for name, gkey, sk, count, offs in rows
|
||||
"sort_key": int(sk or 0), "count": count,
|
||||
# Portion of `count` borrowed from the guitar chart.
|
||||
"inferred_count": int(inferred or 0)}
|
||||
for name, gkey, sk, count, offs, inferred in rows
|
||||
],
|
||||
}
|
||||
|
||||
@@ -330,9 +347,16 @@ class SmartCollectionProvider:
|
||||
# have been hand-edited; never let a bad value reach a query.
|
||||
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
|
||||
|
||||
def _filter_kwargs(self) -> dict:
|
||||
return _library_filter_args(**{k: v for k, v in self._rules.items()
|
||||
def _filter_kwargs(self, instrument: str = "", playable_from_pitch=None) -> dict:
|
||||
# `instrument` is the CALLER's play perspective (rides every request),
|
||||
# never part of the saved rules — a collection saved by a guitarist
|
||||
# must still read in bass tunings for a bass player, and vice versa.
|
||||
args = _library_filter_args(**{k: v for k, v in self._rules.items()
|
||||
if k in _LIBRARY_FILTER_PARAM_KEYS})
|
||||
args["instrument"] = _normalize_instrument(instrument)
|
||||
# The caller's CURRENT tuning is likewise per-request, never a saved rule.
|
||||
args["playable_from_pitch"] = playable_from_pitch
|
||||
return args
|
||||
|
||||
def _sort(self, fallback: str) -> str:
|
||||
# A collection may pin its own sort (e.g. "recently added"); query_page
|
||||
@@ -340,28 +364,31 @@ class SmartCollectionProvider:
|
||||
return self._rules.get("sort") or fallback
|
||||
|
||||
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
|
||||
naming_mode="legacy", **_ignore):
|
||||
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_page(
|
||||
page=page, size=size, sort=self._sort(sort), direction=direction,
|
||||
naming_mode=naming_mode, **self._filter_kwargs())
|
||||
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
|
||||
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy",
|
||||
instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_artists(
|
||||
letter=letter, page=page, size=size, naming_mode=naming_mode,
|
||||
**self._filter_kwargs())
|
||||
**self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
|
||||
def query_albums(self, *, page=0, size=120, naming_mode="legacy",
|
||||
instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_albums(
|
||||
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
|
||||
page=page, size=size, naming_mode=naming_mode,
|
||||
**self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def query_stats(self, *, sort="artist", want_sort_letters=False,
|
||||
naming_mode="legacy", **_ignore):
|
||||
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_stats(
|
||||
sort=self._sort(sort), want_sort_letters=want_sort_letters,
|
||||
naming_mode=naming_mode, **self._filter_kwargs())
|
||||
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def tuning_names(self):
|
||||
return self._local.tuning_names()
|
||||
def tuning_names(self, instrument: str = "guitar"):
|
||||
return self._local.tuning_names(instrument=_normalize_instrument(instrument))
|
||||
|
||||
async def get_art(self, song_id: str):
|
||||
return await self._local.get_art(song_id)
|
||||
@@ -390,7 +417,10 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
||||
artist: str = "", album: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "") -> dict:
|
||||
has_lyrics: str = "", tunings: str = "",
|
||||
instrument: str = "", tuning_match: str = "",
|
||||
playable_offsets: str = "", playable_instrument: str = "",
|
||||
playable_string_count: str = "") -> dict:
|
||||
fmt = format if format in ("archive", "sloppak", "loose") else ""
|
||||
return {
|
||||
"q": q,
|
||||
@@ -404,9 +434,58 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
||||
"stems_lacks": _split_csv(stems_lacks),
|
||||
"has_lyrics": _parse_has_lyrics(has_lyrics),
|
||||
"tunings": _split_csv(tunings),
|
||||
# Which perspective the tuning facet/filter/sort speaks for (the
|
||||
# caller's play role, NOT a saved rule — see _sanitize_collection_rules).
|
||||
"instrument": _normalize_instrument(instrument),
|
||||
# "Playable without retuning" mode: the caller's CURRENT tuning,
|
||||
# resolved to the one number the comparison needs. None = exact-match
|
||||
# mode (the default), so the tuning pills behave exactly as before.
|
||||
"playable_from_pitch": (
|
||||
_playable_from_pitch(playable_offsets, playable_instrument,
|
||||
playable_string_count)
|
||||
if tuning_match == "playable" else None),
|
||||
}
|
||||
|
||||
|
||||
def _playable_from_pitch(offsets_csv: str, instrument: str, string_count: str):
|
||||
"""Lowest open-string MIDI pitch of the CALLER's current tuning.
|
||||
|
||||
The client sends its live working tuning (offsets + instrument + string
|
||||
count) rather than a precomputed pitch, so the pitch tables stay in one
|
||||
place (lib/tunings.py) instead of being duplicated in JS.
|
||||
|
||||
Returns None for anything unusable — the caller then applies NO playable
|
||||
filter at all. That is the neutral state, not a claim: a malformed tuning
|
||||
must not silently assert that everything is playable OR that nothing is.
|
||||
"""
|
||||
try:
|
||||
offsets = [int(x) for x in _split_csv(offsets_csv)]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not offsets:
|
||||
return None
|
||||
inst = "bass" if instrument == "bass" else "guitar"
|
||||
try:
|
||||
sc = int(string_count)
|
||||
except (TypeError, ValueError):
|
||||
sc = len(offsets)
|
||||
key = tunings_mod.instrument_key(inst, sc)
|
||||
if key not in tunings_mod.STANDARD_OPEN_MIDIS or len(offsets) != sc:
|
||||
return None
|
||||
midis = tunings_mod.tuning_midis_from_offsets(key, offsets)
|
||||
return min(midis) if midis else None
|
||||
|
||||
|
||||
def _normalize_instrument(raw: str) -> str:
|
||||
"""Resolve a tuning PERSPECTIVE id (guitar-lead | guitar-rhythm | bass).
|
||||
|
||||
Tolerates the legacy two-valued vocabulary ("guitar" -> guitar-lead) and
|
||||
falls back to the default for anything unknown — an unrecognised value
|
||||
must never silently change filter semantics."""
|
||||
return raw if raw in PERSPECTIVES else (
|
||||
DEFAULT_PERSPECTIVE if raw != "bass" else "bass")
|
||||
|
||||
|
||||
def _sync_collection_provider(collection: dict) -> None:
|
||||
"""Register (or replace) the provider for one collection."""
|
||||
appstate.library_providers.register(
|
||||
|
||||
+21
-1
@@ -225,13 +225,18 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
|
||||
Returns (arrangements_list, shared_meta).
|
||||
shared_meta contains title/artist/album/year/duration/tuning_offsets
|
||||
sourced from the highest-priority arrangement (lead > combo > rhythm >
|
||||
bass) — picking the guitar tuning when both bass and lead are present.
|
||||
bass) — picking the guitar tuning when both bass and lead are present —
|
||||
plus `bass_tuning_offsets` from the first bass arrangement (None when the
|
||||
folder has none), so the index can carry both tunings.
|
||||
"""
|
||||
arrangements = []
|
||||
# Track which arrangement priority sourced shared_meta so a later,
|
||||
# higher-priority arrangement (lead < bass in sort order) overrides.
|
||||
shared_meta = {}
|
||||
shared_priority = None
|
||||
# First tuning seen per arrangement ROLE, kept alongside the guitar-first
|
||||
# song tuning so the library can answer for the part a player plays.
|
||||
role_tunings: dict[str, list[int] | None] = {"bass": None, "rhythm": None}
|
||||
|
||||
for xml in sorted(_iter_local_xmls(path)):
|
||||
# Trust the XML root over the filename — a custom named
|
||||
@@ -269,6 +274,10 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
|
||||
"duration", "tuning_offsets")}
|
||||
shared_priority = priority
|
||||
|
||||
if (arr_type in role_tunings and role_tunings[arr_type] is None
|
||||
and meta.get("tuning_offsets")):
|
||||
role_tunings[arr_type] = list(meta["tuning_offsets"])
|
||||
|
||||
arrangements.append({
|
||||
"type": arr_type,
|
||||
"name": arr_name,
|
||||
@@ -281,6 +290,8 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
|
||||
a["index"] = i
|
||||
del a["priority"]
|
||||
|
||||
for role, offs in role_tunings.items():
|
||||
shared_meta[f"{role}_tuning_offsets"] = offs
|
||||
return arrangements, shared_meta
|
||||
|
||||
|
||||
@@ -412,6 +423,14 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
|
||||
xml_meta.get("duration", 0))
|
||||
tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
|
||||
xml_meta.get("tuning_offsets"))
|
||||
# Per-role tunings: XML-derived only. A manifest `tuning_offsets` overrides
|
||||
# the SONG tuning (above) but says nothing about WHICH chart it describes,
|
||||
# so it must never be mistaken for a specific part's tuning.
|
||||
role_tunings = {}
|
||||
for role in ("bass", "rhythm"):
|
||||
offs = xml_meta.get(f"{role}_tuning_offsets")
|
||||
role_tunings[f"{role}_tuning_offsets"] = (
|
||||
offs if isinstance(offs, list) and offs else None)
|
||||
|
||||
manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
|
||||
if manifest_arr is not None:
|
||||
@@ -427,6 +446,7 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"tuning_offsets": tuning_offsets,
|
||||
**role_tunings, # None = no arrangement in that role
|
||||
"arrangements": arrangements,
|
||||
"audio_path": str(audio) if audio else None,
|
||||
"art_path": str(art) if art else None,
|
||||
|
||||
@@ -23,9 +23,18 @@ Engine selection
|
||||
Two transcription paths share a common output:
|
||||
|
||||
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
||||
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
||||
reference server already hosts WhisperX alongside Demucs at the same
|
||||
URL).
|
||||
stem to the `/transcribe` endpoint on a feedBack-demucs-server
|
||||
(got-feedBack's reference server already hosts WhisperX alongside
|
||||
Demucs at the same URL).
|
||||
|
||||
It used to POST to `/align`, which is *forced alignment* — "here are
|
||||
the lyrics, tell me when each word is sung". Its `text` field is
|
||||
required and we have no lyrics (transcribing them is the point), so
|
||||
the server answered 422 from FastAPI's validation layer before its
|
||||
handler ran, and remote transcription never worked for anyone
|
||||
(feedBack-plugin-stem-splitter#17). `/transcribe` takes only audio.
|
||||
Requires feedBack-demucs-server ≥ the revision adding that endpoint;
|
||||
an older server answers 404 and the error says so.
|
||||
|
||||
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
||||
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
||||
@@ -416,6 +425,38 @@ def transcribe_vocals_local(
|
||||
|
||||
# ── Remote transcription ────────────────────────────────────────────────────
|
||||
|
||||
_MAX_ERR_BODY = 4000
|
||||
|
||||
|
||||
def _err_body(resp) -> str:
|
||||
"""The server's error body, whole if it plausibly is one, and marked when it isn't.
|
||||
|
||||
This was capped at 300 chars, which is enough for "Internal Server Error" and not much else.
|
||||
The bodies carrying the most diagnosis are the long ones — a FastAPI validation body naming
|
||||
the field it rejected, a 500 whose traceback answers on its LAST line — and those are exactly
|
||||
the ones a 300-char cap decapitates. The cap survives so a server answering with a 2 MB HTML
|
||||
error page can't dump a novel into a log line.
|
||||
"""
|
||||
# Strip FIRST, then measure: a body that is 300 chars of JSON and 3900 of trailing whitespace
|
||||
# is not a long body, and truncating it would cut real content to make room for blanks.
|
||||
text = (getattr(resp, "text", "") or "").strip()
|
||||
if len(text) <= _MAX_ERR_BODY:
|
||||
return text
|
||||
|
||||
# Keep the HEAD **and the TAIL**. Head-only truncation throws away the exception line — and
|
||||
# on a traceback the exception line is the answer. This docstring said as much while the code
|
||||
# did the opposite: it cut off precisely the part it exists to preserve, which is the same
|
||||
# mistake, one level up, as the 300-char cap it replaced.
|
||||
#
|
||||
# The marker sits inside the bound, not past it: otherwise _MAX_ERR_BODY is a suggestion, and
|
||||
# the callers who trust it (a log line, a job record persisted to disk) are the ones surprised.
|
||||
marker = f"\n… [truncated, {len(text)} chars total] …\n"
|
||||
budget = max(0, _MAX_ERR_BODY - len(marker))
|
||||
head = budget * 2 // 3 # context: what was being attempted
|
||||
tail = budget - head # verdict: what actually went wrong
|
||||
return text[:head].rstrip() + marker + text[len(text) - tail:].lstrip()
|
||||
|
||||
|
||||
def transcribe_vocals_remote(
|
||||
vocals_path: Path,
|
||||
server_url: str,
|
||||
@@ -426,7 +467,17 @@ def transcribe_vocals_remote(
|
||||
min_word_score: float = 0.35,
|
||||
progress_cb: ProgressCB = None,
|
||||
) -> list[dict]:
|
||||
"""POST the vocal stem to `{server_url}/align` and parse the response.
|
||||
"""POST the vocal stem to `{server_url}/transcribe` and parse the response.
|
||||
|
||||
NOT `/align` — that endpoint is forced alignment ("here are the lyrics,
|
||||
tell me when each word is sung") and its `text` field is required. We
|
||||
have no lyrics; producing them is the point. Posting there returned a
|
||||
422 from FastAPI's validation layer before the server's handler ran, so
|
||||
remote transcription never worked at all
|
||||
(feedBack-plugin-stem-splitter#17).
|
||||
|
||||
Requires a feedBack-demucs-server carrying `/transcribe`; an older one
|
||||
answers 404 and the raised error says so.
|
||||
|
||||
Expects the server to respond with a JSON object carrying a `words` (or
|
||||
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
|
||||
@@ -454,21 +505,53 @@ def transcribe_vocals_remote(
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
params: dict[str, str] = {}
|
||||
# POST to /transcribe, not /align.
|
||||
#
|
||||
# /align is FORCED ALIGNMENT: "here are the lyrics, tell me when each word is sung". Its
|
||||
# `text` field is required, and we have no lyrics — transcription is the whole point. So the
|
||||
# server rejected every request with a 422 in FastAPI's validation layer, before its handler
|
||||
# ever ran, and remote transcription has never worked for anyone. /transcribe answers the
|
||||
# question we are actually asking and takes only the audio.
|
||||
# (feedBack-plugin-stem-splitter#17; endpoint added in feedBack-demucs-server#14.)
|
||||
#
|
||||
# `language` goes in the FORM BODY, not the query string: the server reads it with
|
||||
# Form(""), and a query param would be silently ignored — so an explicit language hint would
|
||||
# do nothing and Whisper's auto-detection would quietly decide instead, which is exactly the
|
||||
# kind of "it works but it's wrong" that hides for months.
|
||||
form: dict[str, str] = {}
|
||||
if language:
|
||||
params["language"] = language
|
||||
form["language"] = language
|
||||
|
||||
# Everything that can go wrong out here comes back as RuntimeError, which is what the
|
||||
# docstring promises and what the caller catches. A DNS failure, a timeout, a reset
|
||||
# connection or an unreadable stem file would otherwise surface as requests.RequestException
|
||||
# or OSError and escape the one handler written to log-and-continue — turning "this song's
|
||||
# lyrics failed" into "the whole batch died".
|
||||
try:
|
||||
with open(vocals_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{server_url}/align",
|
||||
f"{server_url}/transcribe",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
params=params,
|
||||
data=form or None,
|
||||
headers=headers or None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(f"could not reach the WhisperX server at {server_url}: {e}") from e
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"could not read the vocal stem {vocals_path.name}: {e}") from e
|
||||
|
||||
if resp.status_code == 404:
|
||||
# The endpoint isn't there. Say what that means, because "404" on its own sends someone
|
||||
# hunting for a typo in their URL when the real answer is that their server predates the
|
||||
# feature. (feedBack-demucs-server#14 added /transcribe.)
|
||||
raise RuntimeError(
|
||||
f"the WhisperX server at {server_url} has no /transcribe endpoint (404) — it "
|
||||
f"predates remote transcription support. Update the server, or use 'Check for "
|
||||
f"update' if it is the plugin-managed one."
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
|
||||
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {_err_body(resp)}")
|
||||
|
||||
data = resp.json()
|
||||
|
||||
|
||||
+438
-55
@@ -25,6 +25,8 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from song import compute_smart_names
|
||||
from tunings import DEFAULT_PERSPECTIVE, ROLE_PERSPECTIVES
|
||||
from tunings import perspective as _perspective
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
@@ -34,17 +36,113 @@ log = logging.getLogger("feedBack.server")
|
||||
# raw offsets so distinct customs stay distinct, while named tunings keep
|
||||
# grouping by name (stable across the offsets-column migration). Used by both
|
||||
# the tuning-names listing and the filter WHERE so the contract matches.
|
||||
def _tuning_group_key_sql(alias: str) -> str:
|
||||
"""The tuning grouping key (name for named tunings, raw offsets for
|
||||
customs) against an explicit table alias — the grouped filter law (§7.1)
|
||||
evaluates chart-intrinsic predicates inside a member subquery, where bare
|
||||
column names would resolve against the wrong scope."""
|
||||
return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
|
||||
f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")
|
||||
#
|
||||
# A non-default PERSPECTIVE (guitar-rhythm / bass) swaps every tuning column
|
||||
# for its EFFECTIVE expression: that role's indexed tuning when the song has
|
||||
# such an arrangement, falling back to the guitar-derived song tuning
|
||||
# otherwise — so a song with no rhythm/bass chart (or a row that predates the
|
||||
# columns, NULL there) still groups/filters/sorts instead of disappearing.
|
||||
# guitar-lead reads the original unprefixed columns, so it is byte-identical
|
||||
# to the historical behaviour.
|
||||
def _effective_tuning_cols_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> tuple[str, str, str]:
|
||||
"""(name_sql, offsets_sql, sort_key_sql) for the given perspective."""
|
||||
persp = _perspective(perspective)
|
||||
if not persp.column_prefix:
|
||||
return (f"{alias}.tuning_name", f"{alias}.tuning_offsets", f"{alias}.tuning_sort_key")
|
||||
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
|
||||
return (
|
||||
f"COALESCE(NULLIF({alias}.{persp.column('name')}, ''), {alias}.tuning_name)",
|
||||
f"CASE WHEN {has_own} THEN {alias}.{persp.column('offsets')} ELSE {alias}.tuning_offsets END",
|
||||
f"CASE WHEN {has_own} THEN {alias}.{persp.column('sort_key')} ELSE {alias}.tuning_sort_key END",
|
||||
)
|
||||
|
||||
|
||||
def _effective_low_pitch_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
|
||||
"""Lowest open-string MIDI pitch under this perspective, with the same
|
||||
fallback as the tuning columns — the "playable without retuning"
|
||||
comparison reads it (see tunings.chart_is_playable_in)."""
|
||||
persp = _perspective(perspective)
|
||||
if not persp.column_prefix:
|
||||
return f"{alias}.tuning_low_pitch"
|
||||
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
|
||||
return (f"CASE WHEN {has_own} THEN {alias}.{persp.column('low_pitch')} "
|
||||
f"ELSE {alias}.tuning_low_pitch END")
|
||||
|
||||
|
||||
def _perspective_is_inferred_sql(alias: str, perspective: str) -> str:
|
||||
"""1 when this row is BORROWING the guitar-derived song tuning because it
|
||||
has no chart in the perspective's role. Always 0 for guitar-lead, which is
|
||||
never a fallback."""
|
||||
persp = _perspective(perspective)
|
||||
if not persp.column_prefix:
|
||||
return "0"
|
||||
return f"(CASE WHEN COALESCE({alias}.{persp.column('name')}, '') = '' THEN 1 ELSE 0 END)"
|
||||
|
||||
|
||||
# ── The custom-tuning group key ──────────────────────────────────────────────
|
||||
#
|
||||
# Named tunings group by NAME, which is already serialization-agnostic. Custom
|
||||
# tunings group on a raw offsets STRING, which is not: the same physical bass
|
||||
# tuning stored as "-2 0 0 0" and "-2 0 0 0 0 0" would fragment into two facet
|
||||
# rows with split counts.
|
||||
#
|
||||
# For BASS we therefore group customs on `bass_tuning_key` — the tuning's
|
||||
# absolute open-string PITCHES, computed once at scan time
|
||||
# (tunings.bass_tuning_key) after the padded tail is truncated away. Pitch is
|
||||
# the identity that matters musically and it is serialization-independent, so
|
||||
# one physical tuning is one entry however it was authored. Guitar keeps the
|
||||
# offsets string (unchanged; six-element guitar arrays are not padded).
|
||||
#
|
||||
# The key is built HERE, once, and read by the facet listing, the filter WHERE
|
||||
# and the grouped member-match alike — a facet row that selected a different
|
||||
# set than it counted is exactly the bug this shared expression prevents.
|
||||
def _tuning_group_key_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
|
||||
"""The tuning grouping key (name for named tunings, canonical pitches or
|
||||
raw offsets for customs) against an explicit table alias — the grouped
|
||||
filter law (§7.1) evaluates chart-intrinsic predicates inside a member
|
||||
subquery, where bare column names would resolve against the wrong scope."""
|
||||
persp = _perspective(perspective)
|
||||
name_sql, offsets_sql, _ = _effective_tuning_cols_sql(alias, perspective)
|
||||
if persp.column_prefix:
|
||||
# Fall back to the offsets string when the canonical key is absent
|
||||
# (a fallback row borrowing the guitar tuning, or a row scanned before
|
||||
# the key column existed) so a custom never groups under an empty key.
|
||||
offsets_sql = (f"COALESCE(NULLIF({alias}.{persp.column('key')}, ''), "
|
||||
f"{offsets_sql})")
|
||||
return (f"CASE WHEN {name_sql} = 'Custom Tuning' AND COALESCE({offsets_sql}, '') != '' "
|
||||
f"THEN {offsets_sql} ELSE {name_sql} END")
|
||||
|
||||
|
||||
def _put_perspective_value(meta: dict, col: str):
|
||||
"""Value to store for one per-perspective column on a freshly-scanned row."""
|
||||
if col.endswith("_low_pitch"):
|
||||
val = meta.get(col)
|
||||
return int(val) if isinstance(val, int) else None
|
||||
if col.endswith("_sort_key"):
|
||||
return int(meta.get(col, 0) or 0)
|
||||
return meta.get(col, "") or ""
|
||||
|
||||
|
||||
# ── SQLite metadata cache ─────────────────────────────────────────────────────
|
||||
|
||||
def _arrangements_all_bass(raw) -> bool:
|
||||
"""True when EVERY arrangement on a chart is a bass part (raw ``arrangements``
|
||||
JSON, as stored). Mirrors the library grid's card rule: such a chart's tuning
|
||||
must be scored against bass base pitches, or a 4-string bass tuning read as
|
||||
guitar can false-match a guitarist. A chart with no arrangements is not bass.
|
||||
"""
|
||||
try:
|
||||
arrs = json.loads(raw) if raw else []
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if not isinstance(arrs, list) or not arrs:
|
||||
return False
|
||||
return all(
|
||||
isinstance(a, dict) and re.search(r"\bbass\b", str(a.get("name") or ""), re.I)
|
||||
for a in arrs
|
||||
)
|
||||
|
||||
|
||||
def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
|
||||
"""Fill in missing ``smart_name`` fields and sort arrangements by smart order.
|
||||
|
||||
@@ -381,7 +479,18 @@ class MetadataDB:
|
||||
tuning_offsets TEXT DEFAULT '',
|
||||
genre TEXT DEFAULT '',
|
||||
track_number INTEGER,
|
||||
disc INTEGER
|
||||
disc INTEGER,
|
||||
bass_tuning_name TEXT,
|
||||
bass_tuning_sort_key INTEGER,
|
||||
bass_tuning_offsets TEXT,
|
||||
bass_tuning_key TEXT,
|
||||
bass_tuning_low_pitch INTEGER,
|
||||
rhythm_tuning_name TEXT,
|
||||
rhythm_tuning_sort_key INTEGER,
|
||||
rhythm_tuning_offsets TEXT,
|
||||
rhythm_tuning_key TEXT,
|
||||
rhythm_tuning_low_pitch INTEGER,
|
||||
tuning_low_pitch INTEGER
|
||||
)
|
||||
""")
|
||||
# Idempotent migrations for installs that predate each column.
|
||||
@@ -408,6 +517,32 @@ class MetadataDB:
|
||||
# falls back to title order. Cache; repopulated on rescan.
|
||||
"ALTER TABLE songs ADD COLUMN track_number INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN disc INTEGER",
|
||||
# Bass-arrangement tuning (the KwasimodoZAZA report): the song-level
|
||||
# tuning columns above are guitar-first, so the library filter lied
|
||||
# to bass players when the bass chart is tuned differently. Caches;
|
||||
# repopulated on rescan. NULL (no literal default) is deliberate —
|
||||
# it marks a pre-migration row the scanner must re-extract, while
|
||||
# '' means "extracted, song has no bass arrangement" (see scan.py).
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_name TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_sort_key INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_offsets TEXT",
|
||||
# Canonical grouping key: the bass tuning's absolute open-string
|
||||
# pitches. Keyed on PITCH, not the serialization-dependent offsets
|
||||
# string, so one physical tuning is one facet entry however it was
|
||||
# stored. See tunings.bass_tuning_key.
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_key TEXT",
|
||||
# Lowest open-string MIDI pitch per perspective — the "playable
|
||||
# without retuning" comparison (tunings.chart_is_playable_in).
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_low_pitch INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN tuning_low_pitch INTEGER",
|
||||
# The RHYTHM chart's own tuning: lead and rhythm arrangements can
|
||||
# be tuned differently, which is the same bug a bassist hit,
|
||||
# inside guitar. Same NULL-vs-'' contract as the bass family.
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_name TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_sort_key INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_offsets TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_key TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_low_pitch INTEGER",
|
||||
):
|
||||
try:
|
||||
self.conn.execute(ddl)
|
||||
@@ -614,6 +749,14 @@ class MetadataDB:
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
|
||||
# Cumulative wall-clock play time (career "hours in genre" odometer).
|
||||
# Fed by the same POST /api/stats the recorder already sends; additive
|
||||
# + idempotent like every other song_stats change.
|
||||
try:
|
||||
self.conn.execute(
|
||||
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Playlists + the reserved "Saved for Later" system playlist. Additive.
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
@@ -659,6 +802,16 @@ class MetadataDB:
|
||||
self.conn.execute(_ddl)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Manual playlist ordering (tester ask): `position` orders the
|
||||
# PLAYLISTS themselves (playlist_songs.position orders songs within
|
||||
# one). NULL = unpositioned — those sort alphabetically AFTER the
|
||||
# manually positioned ones, and system playlists stay pinned first
|
||||
# regardless (see list_playlists). Additive, idempotent — same
|
||||
# pattern as `rules`/`kind` above.
|
||||
try:
|
||||
self.conn.execute("ALTER TABLE playlists ADD COLUMN position INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
|
||||
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
|
||||
# analogue. Unlike playlists (which reference owned local songs by
|
||||
@@ -901,6 +1054,9 @@ class MetadataDB:
|
||||
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
|
||||
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
|
||||
"last_position": newer["last_position"],
|
||||
# Play time is additive: both encodings' hours belong to
|
||||
# the one canonical song.
|
||||
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
|
||||
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
|
||||
}
|
||||
# Atomic swap: clear and reinsert the canonicalized set in one txn.
|
||||
@@ -1074,26 +1230,57 @@ class MetadataDB:
|
||||
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
||||
return vals
|
||||
|
||||
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
|
||||
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
|
||||
# so a corrected genre is browsable — the correlated subquery is used ONLY
|
||||
# when genre overrides actually exist; the common case stays on the plain
|
||||
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
|
||||
# write-to-file field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup) →
|
||||
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
|
||||
# only — a 'review'/'failed' candidate's genres could belong to the wrong
|
||||
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
|
||||
# corrected or enriched genre is browsable. The vast majority of converted
|
||||
# packs carry no `genres` manifest key, so without the enrichment leg the
|
||||
# genre facet (and career passports) starve on real libraries. The
|
||||
# correlated subqueries are used ONLY when overrides/enrichment genres
|
||||
# actually exist; the common case stays on the plain indexed `genre`
|
||||
# column. Genre stays a library-only overlay (it isn't a write-to-file
|
||||
# field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_OVERRIDE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
||||
)
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), "
|
||||
"NULLIF(genre, ''), "
|
||||
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
|
||||
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
|
||||
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
|
||||
"'')"
|
||||
)
|
||||
|
||||
def _has_genre_overrides(self) -> bool:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
||||
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
||||
|
||||
def _has_enrichment_genres(self) -> bool:
|
||||
try:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
|
||||
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
|
||||
"LIMIT 1").fetchone() is not None
|
||||
except sqlite3.OperationalError:
|
||||
return False # stand-ins / DBs without the enrichment table
|
||||
|
||||
def _effective_genre_expr(self) -> str:
|
||||
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
|
||||
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
|
||||
"""`genre` normally; the enrichment-aware COALESCE only when trusted
|
||||
enrichment genres exist (which also proves the table exists — a
|
||||
stand-in DB without song_enrichment must never receive SQL that
|
||||
references it); the override-only form when just overrides exist."""
|
||||
if self._has_enrichment_genres():
|
||||
return self._EFFECTIVE_GENRE_SQL
|
||||
if self._has_genre_overrides():
|
||||
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
|
||||
return "genre"
|
||||
|
||||
def set_song_tags(self, filename: str, tags) -> list:
|
||||
"""Replace ALL of a song's tags with the given set (each normalized;
|
||||
@@ -1693,7 +1880,8 @@ class MetadataDB:
|
||||
# ── Per-song practice stats ───────────────────────────────────────────---
|
||||
_STATS_COLS = (
|
||||
"filename", "arrangement", "plays", "best_score", "best_accuracy",
|
||||
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
|
||||
"last_score", "last_accuracy", "last_position", "seconds_total",
|
||||
"last_played_at", "updated_at",
|
||||
)
|
||||
|
||||
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
|
||||
@@ -2060,8 +2248,9 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
|
||||
def record_session(self, filename: str, arrangement: int, *, score: int,
|
||||
accuracy: float, last_position=None) -> dict:
|
||||
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
|
||||
accuracy: float, last_position=None, seconds: float = 0) -> dict:
|
||||
"""Record a scored play: plays += 1, best_* = max, last_* = new.
|
||||
`seconds` (wall-clock play time from the recorder) accrues."""
|
||||
from song_score import merge_stats
|
||||
with self._lock:
|
||||
existing = self._stats_row(filename, int(arrangement))
|
||||
@@ -2071,8 +2260,9 @@ class MetadataDB:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats
|
||||
(filename, arrangement, plays, best_score, best_accuracy,
|
||||
last_score, last_accuracy, last_position, last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
||||
last_score, last_accuracy, last_position, seconds_total,
|
||||
last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
plays = excluded.plays,
|
||||
@@ -2081,32 +2271,62 @@ class MetadataDB:
|
||||
last_score = excluded.last_score,
|
||||
last_accuracy = excluded.last_accuracy,
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), merged["plays"], merged["best_score"],
|
||||
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
|
||||
merged["last_position"]),
|
||||
merged["last_position"], float(seconds or 0)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
|
||||
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
|
||||
def touch_position(self, filename: str, arrangement: int, last_position: float,
|
||||
seconds: float = 0) -> dict:
|
||||
"""Persist just the resume position (no plays/score change), so
|
||||
Continue-Playing works for non-scored plays. Also stamps
|
||||
last_played_at — both /api/stats/recent and /api/session/continue
|
||||
filter/order on it, so a position-only touch must set it or the song
|
||||
never surfaces as 'recent' / 'continue playing'."""
|
||||
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
|
||||
wall-clock play time (career hours odometer)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats (filename, arrangement, last_position,
|
||||
seconds_total, last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), float(last_position), float(seconds or 0)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
|
||||
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
|
||||
"""Accrue wall-clock play time (no plays/score/position change) —
|
||||
the recorder's seconds-only flush for unscored plays that ran to the
|
||||
song's natural end (no resume position to touch there: `song:ended`
|
||||
must not overwrite Continue with the end-of-song offset). Stamps
|
||||
last_played_at like touch_position does: the song WAS played, so
|
||||
/api/stats/recent and Continue ordering must see it. Accepted skew:
|
||||
the recorder retries FAILED flushes later, which stamps recency at
|
||||
retry time — rare (offline corner), self-healing on the next play,
|
||||
and preferable to the alternative (keep-existing would leave repeat
|
||||
plays looking stale, the common case)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
|
||||
last_played_at, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(filename, arrangement) DO UPDATE SET
|
||||
last_position = excluded.last_position,
|
||||
seconds_total = song_stats.seconds_total + excluded.seconds_total,
|
||||
last_played_at = excluded.last_played_at,
|
||||
updated_at = excluded.updated_at""",
|
||||
(filename, int(arrangement), float(last_position)),
|
||||
(filename, int(arrangement), float(seconds)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self._stats_row(filename, int(arrangement))
|
||||
@@ -2330,10 +2550,14 @@ class MetadataDB:
|
||||
|
||||
def list_playlists(self) -> list[dict]:
|
||||
from urllib.parse import quote
|
||||
# Order: system playlists pinned first, then manually positioned user
|
||||
# playlists (position = drag order), then unpositioned ones
|
||||
# alphabetically — so a manual order wins and a playlist created after
|
||||
# a reorder still lands somewhere predictable (see reorder_playlists).
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
|
||||
"WHERE rules IS NULL " # smart collections live in the source picker, not here
|
||||
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
|
||||
"ORDER BY (system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
@@ -2491,7 +2715,9 @@ class MetadataDB:
|
||||
rows = self.conn.execute(
|
||||
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
|
||||
ps.arrangement, ps.work_key, s.arrangements,
|
||||
(s.filename IS NULL) AS dead
|
||||
(s.filename IS NULL) AS dead, s.tuning_offsets,
|
||||
s.bass_tuning_name, s.bass_tuning_offsets,
|
||||
s.rhythm_tuning_name, s.rhythm_tuning_offsets
|
||||
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
|
||||
WHERE ps.playlist_id = ? {dead_filter}
|
||||
ORDER BY ps.position, ps.filename""",
|
||||
@@ -2503,6 +2729,17 @@ class MetadataDB:
|
||||
entry = {
|
||||
"filename": r[0], "position": r[1],
|
||||
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
|
||||
# Offsets + the bass-only flag let the playlist tuning check score a
|
||||
# row against the player's working tuning the same way the library
|
||||
# grid's chips do: a NAME alone can't be scored (two "Custom Tuning"
|
||||
# rows are different tunings), and coverage needs to know whether to
|
||||
# measure against bass or guitar base pitches.
|
||||
"tuning_offsets": r[9] or "",
|
||||
"bass_tuning_name": r[10] or "",
|
||||
"bass_tuning_offsets": r[11] or "",
|
||||
"rhythm_tuning_name": r[12] or "",
|
||||
"rhythm_tuning_offsets": r[13] or "",
|
||||
"bass_only": _arrangements_all_bass(r[7]),
|
||||
"art_url": f"/api/song/{quote(r[0])}/art",
|
||||
}
|
||||
if is_album:
|
||||
@@ -2530,7 +2767,9 @@ class MetadataDB:
|
||||
if work_key:
|
||||
self._ensure_work_display()
|
||||
row = self.conn.execute(
|
||||
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
|
||||
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
|
||||
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
|
||||
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
|
||||
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
|
||||
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
|
||||
(work_key,)).fetchone()
|
||||
@@ -2540,8 +2779,16 @@ class MetadataDB:
|
||||
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
|
||||
except Exception:
|
||||
arrs = []
|
||||
# An orphan-resolved slot PLAYS a different chart, so it must report
|
||||
# that chart's tuning to the check — not the dead pin's.
|
||||
return {"resolved_filename": row[0], "title": row[1] or row[0],
|
||||
"artist": row[2] or "", "tuning_name": row[3] or "",
|
||||
"tuning_offsets": row[5] or "",
|
||||
"bass_tuning_name": row[6] or "",
|
||||
"bass_tuning_offsets": row[7] or "",
|
||||
"rhythm_tuning_name": row[8] or "",
|
||||
"rhythm_tuning_offsets": row[9] or "",
|
||||
"bass_only": _arrangements_all_bass(row[4]),
|
||||
"arrangements": arrs,
|
||||
"art_url": f"/api/song/{quote(row[0])}/art",
|
||||
"resolved_from_orphan": True}
|
||||
@@ -2635,6 +2882,30 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def reorder_playlists(self, ordered_ids: list[int]) -> bool:
|
||||
"""Persist a manual ordering of the playlists THEMSELVES: position =
|
||||
index in `ordered_ids` (the songs-within sibling is reorder_playlist).
|
||||
Caller (the route) validates the list is an exact permutation of the
|
||||
current non-system playlist ids."""
|
||||
with self._lock:
|
||||
for pos, pid in enumerate(ordered_ids):
|
||||
self.conn.execute(
|
||||
"UPDATE playlists SET position = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(pos, pid),
|
||||
)
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def clear_playlist_positions(self) -> bool:
|
||||
"""Drop every manual playlist position → back to alphabetical
|
||||
(the "Sort A–Z" affordance)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"UPDATE playlists SET position = NULL, updated_at = datetime('now') "
|
||||
"WHERE position IS NOT NULL")
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def toggle_saved(self, filename: str) -> bool:
|
||||
"""Add/remove a song on the Saved-for-Later playlist. Returns new state.
|
||||
The presence check and the add/remove run under one lock so two
|
||||
@@ -2735,16 +3006,39 @@ class MetadataDB:
|
||||
def favorite_set(self) -> set[str]:
|
||||
return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()}
|
||||
|
||||
# Every per-perspective column, in one place, so the SELECT, the INSERT and
|
||||
# the scanner's "was this ever extracted?" check can never drift apart.
|
||||
# NULL is meaningful on `name`/`key`/`low_pitch`: it marks a row written
|
||||
# before the column existed, which the scanner re-extracts (see
|
||||
# scan._has_unextracted_columns). '' / 0 means "extracted, no such chart".
|
||||
_PERSPECTIVE_COLS = tuple(
|
||||
p.column(suffix)
|
||||
for p in ROLE_PERSPECTIVES
|
||||
for suffix in ("name", "sort_key", "offsets", "key", "low_pitch")
|
||||
) + ("tuning_low_pitch",)
|
||||
# Columns whose NULL means "never extracted" rather than "no such chart".
|
||||
#
|
||||
# low_pitch is deliberately NOT a marker: a song with no chart in that role
|
||||
# legitimately has NULL there (nothing to compute a pitch from), so keying
|
||||
# re-extraction on it would re-scan those rows on every single pass and
|
||||
# never converge. `name` and `key` carry the signal instead — they are ''
|
||||
# when extracted-but-absent, NULL only when the column predates the row.
|
||||
_EXTRACTION_MARKER_COLS = tuple(
|
||||
p.column(suffix) for p in ROLE_PERSPECTIVES for suffix in ("name", "key")
|
||||
)
|
||||
|
||||
def get(self, filename: str, mtime: float, size: int) -> dict | None:
|
||||
cache_key = str(filename)
|
||||
pcols = ", ".join(self._PERSPECTIVE_COLS)
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
"SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
|
||||
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
|
||||
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, "
|
||||
f"{pcols} "
|
||||
"FROM songs WHERE filename = ?", (cache_key,)
|
||||
).fetchone()
|
||||
if row and row[0] == mtime and row[1] == size and row[2]:
|
||||
return {
|
||||
out = {
|
||||
"title": row[2], "artist": row[3], "album": row[4],
|
||||
"year": row[5], "duration": row[6], "tuning": row[7],
|
||||
"arrangements": json.loads(row[8]) if row[8] else [],
|
||||
@@ -2756,6 +3050,15 @@ class MetadataDB:
|
||||
"tuning_sort_key": int(row[14] or 0),
|
||||
"tuning_offsets": row[15] or "",
|
||||
}
|
||||
for i, col in enumerate(self._PERSPECTIVE_COLS, start=16):
|
||||
val = row[i]
|
||||
if col in self._EXTRACTION_MARKER_COLS:
|
||||
out[col] = val # NULL preserved — drives re-extraction
|
||||
elif col.endswith("_sort_key"):
|
||||
out[col] = int(val or 0)
|
||||
else:
|
||||
out[col] = val or ""
|
||||
return out
|
||||
return None
|
||||
|
||||
def put(self, filename: str, mtime: float, size: int, meta: dict):
|
||||
@@ -2763,8 +3066,9 @@ class MetadataDB:
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO songs "
|
||||
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
|
||||
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc, "
|
||||
+ ", ".join(self._PERSPECTIVE_COLS) + ") "
|
||||
"VALUES (" + ", ".join(["?"] * (20 + len(self._PERSPECTIVE_COLS))) + ")",
|
||||
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
|
||||
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
|
||||
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
|
||||
@@ -2777,7 +3081,14 @@ class MetadataDB:
|
||||
meta.get("tuning_offsets", "") or "",
|
||||
meta.get("genre", "") or "",
|
||||
meta.get("track_number"),
|
||||
meta.get("disc")),
|
||||
meta.get("disc"),
|
||||
# A put() row is by definition freshly extracted, so the
|
||||
# marker columns must never be written NULL — that state is
|
||||
# reserved for rows predating the column, which re-extract.
|
||||
# low_pitch is the exception: NULL there means "this tuning
|
||||
# has no computable pitch" (unusable offsets), and the
|
||||
# playable filter treats unknown as not-playable.
|
||||
*[_put_perspective_value(meta, col) for col in self._PERSPECTIVE_COLS]),
|
||||
)
|
||||
self.conn.commit()
|
||||
# A song's identity may have changed → the grouping read-model is stale.
|
||||
@@ -3257,6 +3568,8 @@ class MetadataDB:
|
||||
match_states: list[str] | None = None,
|
||||
genre: list[str] | None = None,
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None,
|
||||
include_intrinsic: bool = True) -> tuple[str, list]:
|
||||
"""Shared WHERE-clause builder for query_page / query_artists /
|
||||
query_stats. Returns (where_sql, params). Leading 'WHERE' is
|
||||
@@ -3363,7 +3676,8 @@ class MetadataDB:
|
||||
"songs", format_filter=format_filter,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
where += ifrag
|
||||
params += iparams
|
||||
return where, params
|
||||
@@ -3375,7 +3689,9 @@ class MetadataDB:
|
||||
stems_lacks: list[str] | None = None,
|
||||
has_lyrics: int | None = None,
|
||||
tunings: list[str] | None = None,
|
||||
naming_mode: str = "legacy") -> tuple[str, list]:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[str, list]:
|
||||
"""CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
|
||||
tuning) as ' AND …' fragments against an explicit table alias. Flat
|
||||
queries apply them to `songs` directly; grouped queries evaluate them
|
||||
@@ -3518,10 +3834,32 @@ class MetadataDB:
|
||||
placeholders = ",".join(["?"] * len(tn))
|
||||
# Match the same grouping key tuning_names() returns so a single
|
||||
# "Custom Tuning" pill selects exactly its offset set while named
|
||||
# tunings still match by name.
|
||||
where += (f" AND {_tuning_group_key_sql(alias)} "
|
||||
# tunings still match by name. `instrument` swaps in the
|
||||
# effective bass tuning key (guitar fallback) — the facet and
|
||||
# this WHERE must use the same expression or they disagree.
|
||||
where += (f" AND {_tuning_group_key_sql(alias, instrument)} "
|
||||
f"COLLATE NOCASE IN ({placeholders})")
|
||||
params += tn
|
||||
if playable_from_pitch is not None:
|
||||
# "Playable without retuning" — the mode the tester actually wants
|
||||
# ("don't make me retune"), offered ALONGSIDE exact match, not
|
||||
# instead of it. A chart needs no retune when its lowest required
|
||||
# pitch is reachable, and every pitch above your lowest open string
|
||||
# is reachable by fretting, so the comparison is:
|
||||
#
|
||||
# your lowest open pitch <= the chart's lowest open pitch
|
||||
#
|
||||
# That is why a 5-string bass (low B) covers every 4-string
|
||||
# standard AND every drop-D chart untouched.
|
||||
#
|
||||
# CONSERVATIVE BY CONSTRUCTION: a chart whose low pitch we could
|
||||
# not compute (NULL) is EXCLUDED rather than assumed playable —
|
||||
# wrongly claiming playability costs a mid-practice retune, which
|
||||
# is the failure this whole feature exists to prevent. See
|
||||
# tunings.chart_is_playable_in for the full reasoning + limits.
|
||||
low_sql = _effective_low_pitch_sql(alias, instrument)
|
||||
where += f" AND {low_sql} IS NOT NULL AND {low_sql} >= ?"
|
||||
params.append(int(playable_from_pitch))
|
||||
return where, params
|
||||
|
||||
# Under group=1, chart-intrinsic filters match if ANY member of the work
|
||||
@@ -3789,7 +4127,9 @@ class MetadataDB:
|
||||
genre: list[str] | None = None,
|
||||
after: str | None = None,
|
||||
group: bool = False,
|
||||
naming_mode: str = "legacy") -> tuple[list[dict], int]:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
|
||||
"""Server-side paginated search. Returns (songs, total_count).
|
||||
|
||||
`after` is an opaque keyset cursor (the last row of the previous page).
|
||||
@@ -3818,7 +4158,9 @@ class MetadataDB:
|
||||
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
|
||||
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
|
||||
match_states=match_states, genre=genre,
|
||||
naming_mode=naming_mode, include_intrinsic=not group,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
include_intrinsic=not group,
|
||||
)
|
||||
ifrag, iparams = "", []
|
||||
if group:
|
||||
@@ -3827,12 +4169,14 @@ class MetadataDB:
|
||||
"m", format_filter=format_filter,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||
where += mfrag
|
||||
params += mparams
|
||||
where += self._GROUP_REP_PREDICATE
|
||||
|
||||
_eff_tuning_name, _, _eff_tuning_sort = _effective_tuning_cols_sql("songs", instrument)
|
||||
sort_map = {
|
||||
# Artist sorts order WITHIN an artist by title (the tree view's
|
||||
# artist -> album -> title feel) instead of raw filename — the
|
||||
@@ -3866,11 +4210,15 @@ class MetadataDB:
|
||||
# behind, and a NULL `tuning_name` in `(tuning_name = '')`
|
||||
# evaluates to NULL itself (which sorts ahead of 0 in
|
||||
# ASC), defeating the push-to-bottom intent.
|
||||
#
|
||||
# Under `instrument=bass` the effective expressions swap in
|
||||
# the bass arrangement's tuning (guitar fallback) so a bass
|
||||
# player's tuning sort orders by the tuning they'd play.
|
||||
"tuning": (
|
||||
"(COALESCE(tuning_name, '') = '') ASC, "
|
||||
"ABS(COALESCE(tuning_sort_key, 0)), "
|
||||
"COALESCE(tuning_sort_key, 0) ASC, "
|
||||
"COALESCE(tuning_name, '') COLLATE NOCASE"
|
||||
f"(COALESCE({_eff_tuning_name}, '') = '') ASC, "
|
||||
f"ABS(COALESCE({_eff_tuning_sort}, 0)), "
|
||||
f"COALESCE({_eff_tuning_sort}, 0) ASC, "
|
||||
f"COALESCE({_eff_tuning_name}, '') COLLATE NOCASE"
|
||||
),
|
||||
# Year sort (feedBack#128). Empty-year rows pushed to the
|
||||
# bottom for both directions; otherwise CAST so '2010' >
|
||||
@@ -3963,7 +4311,9 @@ class MetadataDB:
|
||||
|
||||
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
|
||||
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
|
||||
"tuning_name, tuning_offsets FROM songs ")
|
||||
"tuning_name, tuning_offsets, bass_tuning_name, bass_tuning_offsets, "
|
||||
"rhythm_tuning_name, rhythm_tuning_offsets "
|
||||
"FROM songs ")
|
||||
cursor = _decode_cursor(after) if after else None
|
||||
eff_sort = _effective_keyset_sort(sort, direction)
|
||||
if cursor and eff_sort in _KEYSET_SORTS:
|
||||
@@ -3996,8 +4346,30 @@ class MetadataDB:
|
||||
"stem_ids": json.loads(r[12]) if r[12] else [],
|
||||
"tuning_name": r[13] or "",
|
||||
"tuning_offsets": r[14] or "",
|
||||
# '' when the song has no bass arrangement (or the row predates
|
||||
# '' when the song has no such chart (or the row predates the
|
||||
# columns) — clients fall back to tuning_name.
|
||||
"bass_tuning_name": r[15] or "",
|
||||
"bass_tuning_offsets": r[16] or "",
|
||||
"rhythm_tuning_name": r[17] or "",
|
||||
"rhythm_tuning_offsets": r[18] or "",
|
||||
"has_estd": r[0] in estd, "favorite": r[0] in favs,
|
||||
})
|
||||
# PROVENANCE (non-default perspectives): a row shown to a bass or
|
||||
# rhythm player either carries that chart's own tuning (native) or is
|
||||
# borrowing the guitar-derived song tuning (inferred). The fallback is
|
||||
# deliberate — a third of a real library has no bass chart and
|
||||
# excluding it would be worse — but it must never be SILENT, or we
|
||||
# reproduce the original bug in a new place. The client marks inferred
|
||||
# rows; it can't infer this itself without duplicating the COALESCE.
|
||||
#
|
||||
# guitar-lead adds NOTHING here, so the default payload is unchanged.
|
||||
_persp = _perspective(instrument)
|
||||
if _persp.column_prefix:
|
||||
_name_key = _persp.column("name")
|
||||
for s in songs:
|
||||
s["tuning_perspective"] = _persp.id
|
||||
s["tuning_inferred"] = not s.get(_name_key)
|
||||
# Personal layer (difficulty + tags) rides along like `favorite`, so a
|
||||
# card can badge it without a second request. Notes stay OUT of the list
|
||||
# payload (they can be long) — fetch per-song via /user-meta. Batched to
|
||||
@@ -4094,7 +4466,7 @@ class MetadataDB:
|
||||
rows = self.conn.execute(
|
||||
"SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
|
||||
"m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
|
||||
"m.tuning_name, m.tuning_offsets "
|
||||
"m.tuning_name, m.tuning_offsets, m.bass_tuning_name, m.bass_tuning_offsets "
|
||||
"FROM songs m JOIN work_display mw ON mw.filename = m.filename "
|
||||
f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
|
||||
"ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
|
||||
@@ -4115,6 +4487,7 @@ class MetadataDB:
|
||||
"stem_count": int(m[9] or 0),
|
||||
"stem_ids": json.loads(m[10]) if m[10] else [],
|
||||
"tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
|
||||
"bass_tuning_name": m[13] or "", "bass_tuning_offsets": m[14] or "",
|
||||
}
|
||||
|
||||
def query_artists(self, letter: str = "", q: str = "",
|
||||
@@ -4129,7 +4502,9 @@ class MetadataDB:
|
||||
stems_lacks: list[str] | None = None,
|
||||
has_lyrics: int | None = None,
|
||||
tunings: list[str] | None = None,
|
||||
naming_mode: str = "legacy") -> tuple[list[dict], int]:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
|
||||
"""Get artists grouped by letter with their albums and songs. Returns (artists, total_artists)."""
|
||||
where, params = self._build_where(
|
||||
q=q, favorites_only=favorites_only, format_filter=format_filter,
|
||||
@@ -4137,6 +4512,7 @@ class MetadataDB:
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch,
|
||||
)
|
||||
# Canonicalize artists at display when aliases exist (P4): dedupe / group /
|
||||
# letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one
|
||||
@@ -4172,7 +4548,7 @@ class MetadataDB:
|
||||
|
||||
rows = self.conn.execute(
|
||||
f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, "
|
||||
f"format, stem_count, stem_ids, tuning_name "
|
||||
f"format, stem_count, stem_ids, tuning_name, bass_tuning_name "
|
||||
f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
|
||||
song_params
|
||||
).fetchall()
|
||||
@@ -4205,6 +4581,7 @@ class MetadataDB:
|
||||
"stem_count": int(r[10] or 0),
|
||||
"stem_ids": json.loads(r[11]) if r[11] else [],
|
||||
"tuning_name": r[12] or "",
|
||||
"bass_tuning_name": r[13] or "",
|
||||
"has_estd": r[0] in estd,
|
||||
"favorite": r[0] in favs,
|
||||
"user_difficulty": udm.get(r[0]),
|
||||
@@ -4226,7 +4603,8 @@ class MetadataDB:
|
||||
stems_has=None, stems_lacks=None,
|
||||
has_lyrics=None, tunings=None, mastery=None,
|
||||
match_states=None, genre=None,
|
||||
naming_mode="legacy", page=0, size=120):
|
||||
naming_mode="legacy", instrument=DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch=None, page=0, size=120):
|
||||
"""Distinct (artist, album) groups with a track count + a representative
|
||||
cover song, for the album-condensed browse (paged by album). Rows with no
|
||||
album name are excluded -- they can't form an album card. Same filters as
|
||||
@@ -4238,7 +4616,8 @@ class MetadataDB:
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
|
||||
match_states=match_states, genre=genre,
|
||||
naming_mode=naming_mode,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
)
|
||||
awhere = where + " AND album IS NOT NULL AND album != ''"
|
||||
total = self.conn.execute(
|
||||
@@ -4269,7 +4648,9 @@ class MetadataDB:
|
||||
sort: str = "artist",
|
||||
want_sort_letters: bool = False,
|
||||
group: bool = False,
|
||||
naming_mode: str = "legacy") -> dict:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> dict:
|
||||
"""Aggregate stats for the letter bar. Accepts the same filter
|
||||
params as query_page so the letter counts stay synchronized
|
||||
with the grid when filters are active.
|
||||
@@ -4296,7 +4677,8 @@ class MetadataDB:
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, match_states=match_states,
|
||||
naming_mode=naming_mode,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
include_intrinsic=not group,
|
||||
)
|
||||
if group:
|
||||
@@ -4308,7 +4690,8 @@ class MetadataDB:
|
||||
"m", format_filter=format_filter,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||
where += mfrag
|
||||
params += mparams
|
||||
|
||||
+37
-11
@@ -54,15 +54,20 @@ MIDDLE_C = 60
|
||||
|
||||
def decode_wire_notes(arr_data: dict) -> list[dict]:
|
||||
"""Decode an arrangement JSON's notes + chord notes to
|
||||
``[{"t": float, "midi": int, "sus": float}, ...]`` sorted by time.
|
||||
``[{"t": float, "midi": int, "sus": float, "hand": str|None}, ...]``
|
||||
sorted by time.
|
||||
|
||||
Keys content packs absolute MIDI as ``midi = s*24 + f`` (sloppak-spec
|
||||
§5.3 legacy fallback). Sustain is the ``sus`` field (``l`` accepted as a
|
||||
legacy alias). Entries with malformed fields are skipped.
|
||||
legacy alias). ``hand`` is the authored per-note hand assignment
|
||||
(``'lh'``/``'rh'`` — e.g. from a MusicXML grand-staff import via the
|
||||
editor); a strict enum decode, anything else reads as ``None``
|
||||
(unassigned) so junk can never steer the hand split. Entries with
|
||||
malformed fields are skipped.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
|
||||
def _push(t, s, f, sus):
|
||||
def _push(t, s, f, sus, hand):
|
||||
try:
|
||||
t = float(t)
|
||||
midi = int(s) * 24 + int(f)
|
||||
@@ -70,11 +75,15 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if 0 <= midi <= 127:
|
||||
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
|
||||
out.append({
|
||||
"t": t, "midi": midi, "sus": max(0.0, sus),
|
||||
"hand": hand if hand in ("lh", "rh") else None,
|
||||
})
|
||||
|
||||
for n in arr_data.get("notes") or []:
|
||||
if isinstance(n, dict):
|
||||
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
|
||||
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")),
|
||||
n.get("hand"))
|
||||
for ch in arr_data.get("chords") or []:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
@@ -83,7 +92,7 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
|
||||
if isinstance(cn, dict):
|
||||
# Chord notes carry no own time — they sound at the chord's t.
|
||||
_push(cn.get("t", ch_t), cn.get("s"), cn.get("f"),
|
||||
cn.get("sus", cn.get("l")))
|
||||
cn.get("sus", cn.get("l")), cn.get("hand"))
|
||||
|
||||
out.sort(key=lambda n: (n["t"], n["midi"]))
|
||||
return out
|
||||
@@ -103,14 +112,31 @@ def group_simultaneous(notes: list[dict]) -> list[list[dict]]:
|
||||
|
||||
|
||||
def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
|
||||
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
|
||||
"""Assign every note to ``rh`` or ``lh``.
|
||||
|
||||
Per simultaneous group: a span > 12 semitones splits at the largest
|
||||
internal interval gap (low side → lh); otherwise the whole group goes by
|
||||
mean pitch vs middle C (≥ 60 → rh).
|
||||
An AUTHORED per-note ``hand`` ('lh'/'rh' — a MusicXML grand-staff import
|
||||
or a hand edit in the editor) always wins: those notes go straight to
|
||||
their hand and are REMOVED from the group before any heuristic math runs,
|
||||
so one explicit assignment can never skew its chordmates' guesses (e.g.
|
||||
an authored LH melody note above middle C must not drag the group mean
|
||||
down and flip the remaining notes).
|
||||
|
||||
The remaining unassigned notes take the heuristic, per simultaneous
|
||||
group: a span > 12 semitones splits at the largest internal interval gap
|
||||
(low side → lh); otherwise the whole group goes by mean pitch vs middle C
|
||||
(≥ 60 → rh).
|
||||
"""
|
||||
hands: dict[str, list[dict]] = {"rh": [], "lh": []}
|
||||
for group in group_simultaneous(notes):
|
||||
for full_group in group_simultaneous(notes):
|
||||
# Authored hands first — explicit notes leave the group entirely.
|
||||
group = []
|
||||
for n in full_group:
|
||||
if n.get("hand") in ("lh", "rh"):
|
||||
hands[n["hand"]].append(n)
|
||||
else:
|
||||
group.append(n)
|
||||
if not group:
|
||||
continue
|
||||
pitches = sorted(n["midi"] for n in group)
|
||||
span = pitches[-1] - pitches[0]
|
||||
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
|
||||
|
||||
+42
-13
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
|
||||
import appstate
|
||||
from library_registry import (
|
||||
_library_filter_args, _sanitize_collection_rules,
|
||||
_library_filter_args, _normalize_instrument, _sanitize_collection_rules,
|
||||
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
|
||||
_unregister_collection_provider,
|
||||
)
|
||||
@@ -52,7 +52,8 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
|
||||
|
||||
|
||||
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
|
||||
"mastery", "match_states")
|
||||
"mastery", "match_states", "instrument",
|
||||
"playable_from_pitch")
|
||||
|
||||
|
||||
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
|
||||
@@ -235,9 +236,20 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
mastery: str = "", tags: str = "", user_difficulty: str = "",
|
||||
match: str = "", genre: str = "", after: str = "", group: int = 0,
|
||||
naming_mode: str = "legacy"):
|
||||
naming_mode: str = "legacy", instrument: str = "",
|
||||
tuning_match: str = "", playable_offsets: str = "",
|
||||
playable_instrument: str = "", playable_string_count: str = ""):
|
||||
"""Paginated library search through the selected library provider.
|
||||
|
||||
`instrument` is the tuning PERSPECTIVE ("guitar-lead" default |
|
||||
"guitar-rhythm" | "bass"): which arrangement's tuning the tuning
|
||||
filter/sort speaks for, with a guitar fallback when a song has no chart in
|
||||
that role.
|
||||
|
||||
`tuning_match=playable` switches the tuning filter from exact-match to
|
||||
"playable without retuning" against the caller's current tuning
|
||||
(`playable_offsets` + `playable_instrument` + `playable_string_count`).
|
||||
|
||||
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
|
||||
`next_cursor` from the previous response to fetch the next page with a
|
||||
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
|
||||
@@ -270,7 +282,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
tuning_match=tuning_match, playable_offsets=playable_offsets,
|
||||
playable_instrument=playable_instrument,
|
||||
playable_string_count=playable_string_count,
|
||||
),
|
||||
)
|
||||
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||
@@ -292,7 +307,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", mastery: str = "",
|
||||
match: str = "", genre: str = "",
|
||||
provider: str = "local"):
|
||||
provider: str = "local", instrument: str = ""):
|
||||
"""Album-condensed browse: distinct (artist, album) groups with a track count
|
||||
and a representative cover song. Paged by album. Same filters as /api/library."""
|
||||
size = min(size, 500)
|
||||
@@ -306,7 +321,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
|
||||
q=q, favorites=favorites, format=format, artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
),
|
||||
)
|
||||
return {"albums": albums, "total": total, "page": page, "size": size}
|
||||
@@ -319,7 +334,9 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
naming_mode: str = "legacy"):
|
||||
naming_mode: str = "legacy", instrument: str = "",
|
||||
tuning_match: str = "", playable_offsets: str = "",
|
||||
playable_instrument: str = "", playable_string_count: str = ""):
|
||||
"""Get artists grouped by letter with albums and songs (for tree view)."""
|
||||
size = min(size, 100)
|
||||
library_provider = _get_library_provider(provider)
|
||||
@@ -336,7 +353,7 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
),
|
||||
)
|
||||
return {"artists": artists, "total_artists": total, "page": page, "size": size}
|
||||
@@ -350,7 +367,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
match: str = "",
|
||||
sort: str = "artist", sort_letters: int = 0,
|
||||
group: int = 0, naming_mode: str = "legacy"):
|
||||
group: int = 0, naming_mode: str = "legacy",
|
||||
instrument: str = "", tuning_match: str = "",
|
||||
playable_offsets: str = "", playable_instrument: str = "",
|
||||
playable_string_count: str = ""):
|
||||
"""Aggregate stats for the UI. Accepts the same filter params as
|
||||
/api/library so the letter bar mirrors the active grid filter set.
|
||||
`sort` selects the column the jump rail's `sort_letters` keys on;
|
||||
@@ -375,7 +395,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
tuning_match=tuning_match, playable_offsets=playable_offsets,
|
||||
playable_instrument=playable_instrument,
|
||||
playable_string_count=playable_string_count,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -407,14 +430,20 @@ def library_genres(provider: str = "local"):
|
||||
|
||||
|
||||
@router.get("/api/library/tuning-names")
|
||||
async def list_tuning_names(provider: str = "local"):
|
||||
async def list_tuning_names(provider: str = "local", instrument: str = ""):
|
||||
"""Distinct tuning names present in the library, with per-tuning
|
||||
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
|
||||
so names appear in the same musical order the sort uses
|
||||
(feedBack#22) — E Standard first, then nearest neighbors."""
|
||||
(feedBack#22) — E Standard first, then nearest neighbors.
|
||||
|
||||
`instrument=bass` groups by each song's bass-arrangement tuning
|
||||
(guitar-derived fallback for songs without a bass chart) so bass
|
||||
players see the tunings they'd actually play. Providers that predate
|
||||
the kwarg simply don't receive it (signature-filtered)."""
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
return await _call_library_provider_async(library_provider, "tuning_names")
|
||||
return await _call_library_provider_async(
|
||||
library_provider, "tuning_names", instrument=_normalize_instrument(instrument))
|
||||
|
||||
|
||||
@router.get("/api/library/practice-suggestions")
|
||||
|
||||
@@ -70,6 +70,37 @@ def api_create_playlist(data: dict):
|
||||
return appstate.meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@router.post("/api/playlists/reorder")
|
||||
def api_reorder_playlists(data: dict):
|
||||
"""Manual ordering of the playlists themselves (position = index in
|
||||
`order`); the songs-within sibling is /api/playlists/{pid}/reorder.
|
||||
System playlists stay pinned first and are not part of the order."""
|
||||
order = data.get("order")
|
||||
if not isinstance(order, list) or not all(
|
||||
isinstance(i, int) and not isinstance(i, bool) for i in order):
|
||||
return JSONResponse({"error": "order must be a list of playlist ids"}, status_code=400)
|
||||
# Require an exact permutation of the current non-system playlist ids: a
|
||||
# list with duplicates, omissions, extras, unknown ids, or a system id
|
||||
# would otherwise produce duplicate positions / a partial reorder while
|
||||
# still returning 200 (mirrors the songs-within validation).
|
||||
current = [p["id"] for p in appstate.meta_db.list_playlists() if not p["system_key"]]
|
||||
if len(order) != len(current) or sorted(order) != sorted(current):
|
||||
return JSONResponse(
|
||||
{"error": "order must be a permutation of your playlists' ids"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.reorder_playlists(order)
|
||||
return api_list_playlists()
|
||||
|
||||
|
||||
@router.post("/api/playlists/sort-alpha")
|
||||
def api_sort_playlists_alpha():
|
||||
"""Clear every manual playlist position → back to the alphabetical
|
||||
default (system playlists were pinned first either way)."""
|
||||
appstate.meta_db.clear_playlist_positions()
|
||||
return api_list_playlists()
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}")
|
||||
def api_get_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
|
||||
+70
-5
@@ -829,9 +829,61 @@ def post_song_gap_fill(filename: str, data: dict):
|
||||
return {"ok": True, "written": additions, "skipped": skipped}
|
||||
|
||||
|
||||
def _playable_stems_payload(filename: str, dlc) -> dict:
|
||||
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
|
||||
|
||||
Why it exists: the stems plugin could only learn its stem list from the
|
||||
highway's WS `ready`, which arrives once the highway is already up. So it
|
||||
decoded, and then copied the whole song's PCM to its worklet, with the player
|
||||
on screen — half a gigabyte of memcpy in one frame, ~700 ms, freezing the
|
||||
venue video. Given the list at `song:loading` it can do all of that BEFORE the
|
||||
highway appears, behind the loading overlay where a stall costs nothing.
|
||||
|
||||
The list MUST be the same one the WS sends a moment later. If it is not, the
|
||||
plugin preloads a graph and then throws it away and rebuilds — strictly worse
|
||||
than not preloading. So this does not reimplement the WS's construction, it
|
||||
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
|
||||
partitioned stems and the resolved full mix, and then builds the URLs exactly
|
||||
as ws_highway does. Drift is impossible by construction rather than by
|
||||
agreement — which matters, because `full_mix` in particular is not simply the
|
||||
`full` stem: load_song falls back to the deprecated `original_audio:` key for
|
||||
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
|
||||
first) silently dropped the pristine full mix for most real libraries.
|
||||
|
||||
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
|
||||
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
|
||||
to preload: load_song raises and we return the empty list.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
|
||||
except Exception:
|
||||
return {"stems": [], "full_mix_url": None}
|
||||
|
||||
q_fn = quote(filename, safe="")
|
||||
|
||||
def _url(rel: str) -> str:
|
||||
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
|
||||
|
||||
return {
|
||||
"stems": [
|
||||
{"id": s["id"], "url": _url(s["file"]), "default": s["default"],
|
||||
**{k: s[k] for k in ("name", "description") if k in s}}
|
||||
for s in loaded.stems
|
||||
],
|
||||
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}")
|
||||
async def get_song_info(filename: str):
|
||||
"""Return song metadata, from cache or by extracting it from the song source."""
|
||||
async def get_song_info(filename: str, stems: int = 0):
|
||||
"""Return song metadata, from cache or by extracting it from the song source.
|
||||
|
||||
`?stems=1` additionally returns the playable stem list with URLs, so the
|
||||
stems plugin can start fetching/decoding on `song:loading` instead of waiting
|
||||
for the highway's WS `ready` (see _playable_stems_payload).
|
||||
"""
|
||||
import asyncio
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
@@ -854,8 +906,21 @@ async def get_song_info(filename: str):
|
||||
|
||||
mtime, size = appstate.stat_for_cache(song_path)
|
||||
cached = appstate.meta_db.get(cache_key, mtime, size)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# The stem list is NOT stored in the metadata cache: that is a fixed-column
|
||||
# table, and widening it would mean a migration plus a stale row for every
|
||||
# song already scanned. It is cheap to read on demand (the pack is unpacked
|
||||
# by then, so this is a plain manifest read), and only the opt-in caller pays.
|
||||
async def _with_stems(meta: dict) -> dict:
|
||||
if not stems:
|
||||
return meta
|
||||
extra = await loop.run_in_executor(
|
||||
None, _playable_stems_payload, filename, dlc)
|
||||
return {**meta, **extra}
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
return await _with_stems(cached)
|
||||
|
||||
# Extract in thread pool
|
||||
def _extract():
|
||||
@@ -863,5 +928,5 @@ async def get_song_info(filename: str):
|
||||
appstate.meta_db.put(cache_key, mtime, size, meta)
|
||||
return meta
|
||||
|
||||
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
|
||||
return meta
|
||||
meta = await loop.run_in_executor(None, _extract)
|
||||
return await _with_stems(meta)
|
||||
|
||||
+34
-2
@@ -76,6 +76,22 @@ def api_record_stats(data: dict):
|
||||
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
||||
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# Optional wall-clock play time (career hours odometer). Bounded per POST:
|
||||
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
|
||||
# clock artifact (suspend/sleep), not practice.
|
||||
seconds = data.get("seconds")
|
||||
if seconds is not None:
|
||||
if isinstance(seconds, bool):
|
||||
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
|
||||
try:
|
||||
seconds = float(seconds)
|
||||
if not math.isfinite(seconds):
|
||||
raise ValueError("non-finite")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
|
||||
if not (0 < seconds <= 6 * 3600):
|
||||
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
|
||||
seconds = seconds or 0.0
|
||||
|
||||
# A scored session needs BOTH score and accuracy. Exactly one provided is
|
||||
# ambiguous — don't silently fall through to the position-only branch.
|
||||
@@ -115,7 +131,8 @@ def api_record_stats(data: dict):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
row = appstate.meta_db.record_session(filename, arrangement, score=score,
|
||||
accuracy=accuracy, last_position=last_pos)
|
||||
accuracy=accuracy, last_position=last_pos,
|
||||
seconds=seconds)
|
||||
# Unified XP + streak side-effects — never let these drop the stat write.
|
||||
progress = None
|
||||
try:
|
||||
@@ -152,6 +169,21 @@ def api_record_stats(data: dict):
|
||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress, "progression": progression_summary}
|
||||
|
||||
# Seconds-only accrual: an unscored play that ran to the song's natural
|
||||
# end has play time to bank but no resume position to touch (song:ended
|
||||
# must not overwrite Continue with the end-of-song offset). Still counts
|
||||
# as playing today for the streak below.
|
||||
if last_pos is None and seconds:
|
||||
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
|
||||
progress = None
|
||||
try:
|
||||
from datetime import date
|
||||
appstate.meta_db.record_active_day(date.today().isoformat())
|
||||
progress = appstate.meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (streak) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress}
|
||||
|
||||
# Position-only touch.
|
||||
if last_pos is None:
|
||||
return JSONResponse(
|
||||
@@ -162,7 +194,7 @@ def api_record_stats(data: dict):
|
||||
pos = float(last_pos)
|
||||
if not math.isfinite(pos):
|
||||
raise ValueError("non-finite")
|
||||
row = appstate.meta_db.touch_position(filename, arrangement, pos)
|
||||
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# A resume session still counts as playing today: advance the streak (no XP —
|
||||
|
||||
+108
-34
@@ -26,6 +26,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from song import (
|
||||
anchor_to_wire,
|
||||
arrangement_is_bass,
|
||||
arrangement_string_count,
|
||||
base_open_string_midis,
|
||||
chord_template_to_wire,
|
||||
@@ -143,9 +144,21 @@ def _sanitize_authors(manifest: dict | None) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _drum_part_id_for_wire(drum_parts: list[dict] | None, selected_id: str | None) -> str | None:
|
||||
"""Expose a part id only when the pack genuinely has multiple parts."""
|
||||
return selected_id if selected_id is not None and len(drum_parts or []) > 1 else None
|
||||
|
||||
|
||||
@router.websocket("/ws/highway/{filename:path}")
|
||||
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"):
|
||||
"""Stream song data for the highway renderer over WebSocket."""
|
||||
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
naming_mode: str = "legacy", drum_part: str = ""):
|
||||
"""Stream song data for the highway renderer over WebSocket.
|
||||
|
||||
`drum_part` selects WHICH drum part's tab streams when the pack carries
|
||||
several (feedpak 1.17.0 "drums as arrangements") — a part id from
|
||||
song_info's `drum_parts`. Empty / unknown ids fall back to the primary,
|
||||
so a stale or mistyped selection degrades to today's behavior instead of
|
||||
silencing drums."""
|
||||
await websocket.accept()
|
||||
structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8])
|
||||
|
||||
@@ -261,9 +274,8 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
bass_idxs = [
|
||||
i
|
||||
for i, a in enumerate(song.arrangements)
|
||||
if getattr(a, "path_bass", False)
|
||||
if arrangement_is_bass(a)
|
||||
or (smart_names[i] or "").lower().startswith("bass")
|
||||
or "bass" in (getattr(a, "name", "") or "").lower()
|
||||
]
|
||||
if bass_idxs:
|
||||
# Among the bass parts: (1) honor the saved default-arrangement
|
||||
@@ -321,11 +333,16 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
audio_url = None
|
||||
audio_error: str | None = None # Surfaced in song_info when audio_url is None
|
||||
stems_payload: list[dict] = []
|
||||
# URL of the single full-mix audio (sloppak `original_audio:`), when the
|
||||
# pack ships one. The stems plugin uses this to play the untouched mix
|
||||
# while every stem slider is at unity; None otherwise (separate stems
|
||||
# only, loose folder, or archive).
|
||||
original_audio_url: str | None = None
|
||||
# URL of the pack's complete mixdown — the RESERVED `full` stem (spec
|
||||
# §5.3), which sloppak.load_song() lifts out of `stems` because it is a
|
||||
# mixdown, not a layer. The stems plugin plays it while every stem slider
|
||||
# is at unity (separation is lossy, so it beats re-summing the stems) and
|
||||
# crosses to the separated stems as soon as one is attenuated.
|
||||
#
|
||||
# None when the pack has no mixdown to offer separately from its stems:
|
||||
# a single-mix pack (its one stem IS the mixdown), a loose folder, or an
|
||||
# archive.
|
||||
full_mix_url: str | None = None
|
||||
if is_loose:
|
||||
# Loose folder filenames are relative paths (artist/album/song).
|
||||
# Hash the *canonical* dlc-relative path (so two URL spellings
|
||||
@@ -363,23 +380,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
q_fn = quote(filename, safe="")
|
||||
for s in loaded_slop.stems:
|
||||
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
|
||||
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
|
||||
stems_payload.append(
|
||||
{"id": s["id"], "url": url, "default": s["default"],
|
||||
**{k: s[k] for k in ("name", "description") if k in s}})
|
||||
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
|
||||
if loaded_slop is not None and loaded_slop.original_audio:
|
||||
original_audio_url = (
|
||||
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
|
||||
if loaded_slop is not None and loaded_slop.full_mix:
|
||||
full_mix_url = (
|
||||
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.full_mix)}"
|
||||
)
|
||||
if stems_payload:
|
||||
# Stems present: keep the core <audio> pointed at stem[0]. This
|
||||
# URL is only ever heard in the degraded path (stems plugin
|
||||
# refuses takeover / decode fails); the full-mix↔stems switch is
|
||||
# driven client-side by `original_audio_url`, not `audio_url`.
|
||||
# driven client-side by `full_mix_url`, not `audio_url`.
|
||||
audio_url = stems_payload[0]["url"]
|
||||
elif original_audio_url:
|
||||
elif full_mix_url:
|
||||
# Stem-less full-mix pack: nothing to separate, so play the full
|
||||
# mix natively through the core <audio>. The stems plugin's
|
||||
# onSongReady returns early on an empty stems list (no graph).
|
||||
audio_url = original_audio_url
|
||||
# Reachable only via the deprecated `original_audio:` key, whose
|
||||
# packs put the mixdown outside `stems` — a pack that carries its
|
||||
# mixdown as the `full` stem has it IN `stems`, so it lands in the
|
||||
# branch above with stems_payload == [full].
|
||||
audio_url = full_mix_url
|
||||
else:
|
||||
audio_error = "This sloppak has no playable stems."
|
||||
else:
|
||||
@@ -521,16 +544,31 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# for the credits overlay, so minigames / synthetic highway uses
|
||||
# (no manifest) never trigger it.
|
||||
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
|
||||
# Instrument stems ONLY. The pack's complete mixdown (the RESERVED
|
||||
# `full` stem, spec §5.3) is deliberately NOT in this list: consumers
|
||||
# sum `stems` into one mix and render one fader per entry, and the
|
||||
# mixdown is neither a layer nor an instrument — summing it would
|
||||
# double the whole song. It is surfaced separately, below.
|
||||
"stems": stems_payload,
|
||||
# Full-mix audio (sloppak `original_audio:`) served alongside the
|
||||
# separate `stems`. The stems plugin plays this single file while
|
||||
# every stem slider is at unity and switches to the separate stems
|
||||
# the moment one drops below 100%. None when the pack ships stems
|
||||
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
|
||||
# a client can branch without re-deriving from the URLs.
|
||||
"original_audio_url": original_audio_url,
|
||||
"has_original_audio": bool(original_audio_url),
|
||||
# The complete mixdown, served by the same /api/sloppak/.../file/
|
||||
# endpoint as the stems. The stems plugin plays this single file
|
||||
# while every stem slider is at unity and crosses to the separated
|
||||
# stems the moment one drops below 100% — separation is lossy, so the
|
||||
# mixdown is strictly better audio when nothing is muted. None when
|
||||
# the pack has no mixdown apart from its stems. The `has_*` flags
|
||||
# mirror the has_drum_tab/has_keys convention so a client can branch
|
||||
# without re-deriving from the URLs.
|
||||
"full_mix_url": full_mix_url,
|
||||
"has_full_mix": bool(full_mix_url),
|
||||
"has_stems": bool(stems_payload),
|
||||
# DEPRECATED aliases of the two keys above, kept so a client built
|
||||
# against the old frame keeps working across one release. They were
|
||||
# named after `original_audio:` — a manifest key this repo invented
|
||||
# and the feedpak spec never had (#933). The key is gone; the mixdown
|
||||
# is a stem. Remove these once the shipped stems plugin reads
|
||||
# `full_mix_url` (#945).
|
||||
"original_audio_url": full_mix_url,
|
||||
"has_original_audio": bool(full_mix_url),
|
||||
# Surface a drum_tab presence flag so the visualization picker
|
||||
# can auto-activate the drums plugin even when the chosen
|
||||
# arrangement isn't named "Drums" (drum_tab.json lives next
|
||||
@@ -538,6 +576,15 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
"has_drum_tab": bool(
|
||||
is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None
|
||||
),
|
||||
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"),
|
||||
# primary first — names only; the selected part's payload streams
|
||||
# as the `drum_tab`/`drum_hits` messages below. Always a list
|
||||
# (empty when the pack has no drums, and a single entry for a
|
||||
# legacy one-drum pack), so a part picker can bind unconditionally.
|
||||
"drum_parts": [
|
||||
{"id": p["id"], "name": p["name"]}
|
||||
for p in (loaded_slop.drum_parts or [])
|
||||
] if is_slop and loaded_slop is not None else [],
|
||||
"has_notation": bool(
|
||||
is_slop
|
||||
and loaded_slop is not None
|
||||
@@ -561,18 +608,36 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# client-side drums plugin keeps a fallback decoder for them.
|
||||
if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None:
|
||||
dt = loaded_slop.drum_tab
|
||||
# Multiple drum parts: `?drum_part=<id>` picks which part's tab
|
||||
# streams; the default (and any unknown id) is the PRIMARY —
|
||||
# exactly the pre-parts behavior, so legacy clients notice nothing.
|
||||
_dt_part_id = None
|
||||
if loaded_slop.drum_parts:
|
||||
_dt_part_id = loaded_slop.drum_parts[0]["id"]
|
||||
if drum_part:
|
||||
for _p in loaded_slop.drum_parts:
|
||||
if _p["id"] == drum_part:
|
||||
dt = _p["drum_tab"]
|
||||
_dt_part_id = _p["id"]
|
||||
break
|
||||
kit = drums_mod.normalise_kit(dt.get("kit"))
|
||||
hits_wire = drums_mod.hits_to_wire(dt.get("hits") or [])
|
||||
_dt_name = dt.get("name")
|
||||
_dt_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums"
|
||||
try:
|
||||
await websocket.send_json({
|
||||
_dt_msg = {
|
||||
"type": "drum_tab",
|
||||
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
|
||||
"name": _dt_name,
|
||||
"kit": kit,
|
||||
"total": len(hits_wire),
|
||||
})
|
||||
}
|
||||
# Only multi-part packs identify a part on the wire. Legacy packs
|
||||
# synthesize a one-item list internally but keep their old frame.
|
||||
_wire_part_id = _drum_part_id_for_wire(loaded_slop.drum_parts, _dt_part_id)
|
||||
if _wire_part_id is not None:
|
||||
_dt_msg["part_id"] = _wire_part_id
|
||||
try:
|
||||
await websocket.send_json(_dt_msg)
|
||||
for i in range(0, len(hits_wire), 500):
|
||||
await websocket.send_json({
|
||||
"type": "drum_hits",
|
||||
@@ -709,20 +774,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# (Arrangement.tones, populated by the converter), so read it straight
|
||||
# off `arr` rather than walking for XML that doesn't exist.
|
||||
if is_slop:
|
||||
# `sloppak_tone_changes` builds the (base, sorted changes) pair
|
||||
# from `Arrangement.tones`, skipping non-string names and
|
||||
# non-finite/non-numeric times — unit-tested in test_tones.py.
|
||||
# `sloppak_tone_changes` builds the (base, base_rig, sorted
|
||||
# changes) triple from `Arrangement.tones`, skipping non-string
|
||||
# names, non-finite/non-numeric times, and unusable rig ids —
|
||||
# unit-tested in test_tones.py.
|
||||
from tones import sloppak_tone_changes
|
||||
base_name, tone_changes = sloppak_tone_changes(getattr(arr, "tones", None))
|
||||
base_name, base_rig, tone_changes = sloppak_tone_changes(
|
||||
getattr(arr, "tones", None)
|
||||
)
|
||||
# Send when there's a base tone OR timed changes — a single-tone
|
||||
# arrangement has a base but no switches, and the highway should
|
||||
# still be able to show the initial tone.
|
||||
if tone_changes or base_name:
|
||||
await websocket.send_json({
|
||||
payload = {
|
||||
"type": "tone_changes",
|
||||
"base": base_name,
|
||||
"data": tone_changes,
|
||||
})
|
||||
}
|
||||
# `base_rig` is additive (feedpak-spec §6.9) — omitted entirely
|
||||
# when the chart binds no rig, so consumers that predate the rig
|
||||
# model see the exact payload they always did.
|
||||
if base_rig:
|
||||
payload["base_rig"] = base_rig
|
||||
await websocket.send_json(payload)
|
||||
else:
|
||||
xml_paths = sorted(_xml_walk("*.xml"))
|
||||
|
||||
@@ -949,7 +1023,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# base[string] + offset + capo + fret (matches the tuner / open-string
|
||||
# labels). arrangement_string_count is O(notes), so compute once here.
|
||||
_base = base_open_string_midis(
|
||||
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
|
||||
arrangement_string_count(arr), arrangement_is_bass(arr))
|
||||
_capo = int(getattr(arr, "capo", 0) or 0)
|
||||
|
||||
def _fill_scale_degree(wire: dict, n, t: float) -> None:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030).
|
||||
|
||||
A deliberately dumb fan-out room: a JSON text frame received from one client
|
||||
is forwarded verbatim to every OTHER client connected to the same session id.
|
||||
The server interprets nothing beyond the limits below — message schemas are
|
||||
owned entirely by consumers. First consumer: splitscreen's LAN follower mode
|
||||
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
|
||||
frames from a host window to view-only followers on other LAN devices.
|
||||
|
||||
Design points (full spec in the issue):
|
||||
|
||||
- Rooms are created on first join and garbage-collected when the last socket
|
||||
leaves. No history, no replay, no persistence — a late joiner simply waits
|
||||
for the next frame. Consumers that need state on join re-send it themselves
|
||||
(splitscreen answers every follower ``hello`` with a fresh ``config``).
|
||||
- That statelessness is what makes consumer crash-recovery work: a host that
|
||||
relaunches and rejoins the same session id resumes publishing to its
|
||||
reconnecting subscribers with no server-side coordination, and an idle room
|
||||
is indistinguishable from a nonexistent one.
|
||||
- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``);
|
||||
consumers pick their own id policy (splitscreen uses a short typeable,
|
||||
persistent room key).
|
||||
- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and
|
||||
total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit
|
||||
sockets are closed with a policy code; the room carries on. A peer that dies
|
||||
mid-fan-out is dropped without wedging delivery to the rest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$")
|
||||
|
||||
# Limits. Sized generously above the first consumer's needs (splitscreen
|
||||
# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding
|
||||
# what an open LAN port can be made to do. All module-level so tests (and a
|
||||
# desperate operator) can override them.
|
||||
MAX_FRAME_BYTES = 16 * 1024
|
||||
MAX_CLIENTS_PER_ROOM = 16
|
||||
MAX_ROOMS = 32
|
||||
RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket
|
||||
RATE_BURST = 240.0 # token-bucket burst headroom
|
||||
# A peer that stops draining its socket would leave send_text() pending
|
||||
# forever — and since publishers await the fan-out gather, one stalled peer
|
||||
# would stall every publisher's receive loop behind it. Bounding the send
|
||||
# turns the stall into an eviction through the normal failed-send drop path.
|
||||
SEND_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
# RFC 6455 close codes.
|
||||
_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay
|
||||
_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded
|
||||
_WS_MSG_TOO_BIG = 1009
|
||||
_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity
|
||||
|
||||
# session_id → {socket: per-socket send lock}. The lock serializes concurrent
|
||||
# fan-out sends to the same peer (two publishers relaying at once must not
|
||||
# interleave writes on a third socket's transport).
|
||||
_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {}
|
||||
|
||||
|
||||
async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None:
|
||||
async with lock:
|
||||
await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
@router.websocket("/ws/sync/{session_id}")
|
||||
async def sync_ws(websocket: WebSocket, session_id: str):
|
||||
"""Join the fan-out room *session_id*; relay every inbound text frame."""
|
||||
await websocket.accept()
|
||||
|
||||
if not _SESSION_ID_RE.fullmatch(session_id):
|
||||
await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id")
|
||||
return
|
||||
|
||||
# Capacity checks and insertion run with no await between them, so
|
||||
# concurrent joiners on the event loop can't race past the caps.
|
||||
room = _rooms.get(session_id)
|
||||
if room is None:
|
||||
if len(_rooms) >= MAX_ROOMS:
|
||||
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions")
|
||||
return
|
||||
room = _rooms[session_id] = {}
|
||||
log.debug("ws_sync: room %s created", session_id)
|
||||
elif len(room) >= MAX_CLIENTS_PER_ROOM:
|
||||
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full")
|
||||
return
|
||||
room[websocket] = asyncio.Lock()
|
||||
|
||||
tokens = RATE_BURST
|
||||
last_refill = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
text = message.get("text")
|
||||
if text is None:
|
||||
await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only")
|
||||
break
|
||||
if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES:
|
||||
await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large")
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC)
|
||||
last_refill = now
|
||||
tokens -= 1.0
|
||||
if tokens < 0:
|
||||
await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded")
|
||||
break
|
||||
|
||||
peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket]
|
||||
if not peers:
|
||||
continue
|
||||
results = await asyncio.gather(
|
||||
*(_send_locked(ws, lock, text) for ws, lock in peers),
|
||||
return_exceptions=True,
|
||||
)
|
||||
# A peer that failed mid-send is dropped from the room here; its
|
||||
# own handler finishes cleanup (the finally below) when its
|
||||
# receive loop observes the disconnect.
|
||||
for (peer, _lock), result in zip(peers, results):
|
||||
if isinstance(result, Exception):
|
||||
room.pop(peer, None)
|
||||
finally:
|
||||
room.pop(websocket, None)
|
||||
# Guard against deleting a NEW room another joiner created after this
|
||||
# one emptied (only possible for a dict that is no longer ours).
|
||||
if not room and _rooms.get(session_id) is room:
|
||||
del _rooms[session_id]
|
||||
log.debug("ws_sync: room %s closed", session_id)
|
||||
+31
-3
@@ -4,9 +4,34 @@ under a server-owned root.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def resolved_root(root: Path) -> Path:
|
||||
"""Canonical (link-resolved) form of a server-owned root directory.
|
||||
|
||||
``Path.resolve()`` is a filesystem call: it lstats every component of the
|
||||
path. The roots we join against — the DLC library, a plugin's asset dir —
|
||||
are fixed for the life of the process, but the containment helpers below
|
||||
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
|
||||
and those are called once per song, per art fetch, per scanned row.
|
||||
|
||||
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
|
||||
pinning a core. It is brutal when the library lives on a FUSE mount
|
||||
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
|
||||
three parent directories were being walked over and over.
|
||||
|
||||
Cached because a root is a constant here, not because resolution is cheap.
|
||||
Consequence: if a root's symlink/junction is re-pointed at a NEW target
|
||||
while the server is running, the old target stays in effect until restart.
|
||||
That is fine for a library path fixed at startup, and the cache is keyed on
|
||||
the Path, so switching to a different library dir is a different key.
|
||||
"""
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""Resolve ``name`` under ``root`` and return the resolved Path, or
|
||||
``None`` if it would escape ``root`` or is unrepresentable.
|
||||
@@ -35,9 +60,12 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
candidate = (root_resolved / safe).resolve()
|
||||
if not candidate.is_relative_to(root_resolved):
|
||||
# The ROOT is a constant — resolve it once (see resolved_root). The
|
||||
# CANDIDATE must still be resolved on every call: following its symlinks
|
||||
# is exactly the zip-slip / traversal defence, so it is never cached.
|
||||
root_res = resolved_root(root)
|
||||
candidate = (root_res / safe).resolve()
|
||||
if not candidate.is_relative_to(root_res):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
+230
-5
@@ -51,12 +51,133 @@ from scan_worker import _relpath, _scan_one
|
||||
|
||||
log = logging.getLogger("feedBack.scan")
|
||||
|
||||
import json
|
||||
|
||||
|
||||
# ── Directory-signature fast path ─────────────────────────────────────────────
|
||||
#
|
||||
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
|
||||
# file to detect what changed. On a 50k-song library that lives on a slow mount
|
||||
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
|
||||
# the "big drive churns on every startup" report.
|
||||
#
|
||||
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
|
||||
# holds them (verified on the target NTFS-3G mount), and so does the addition of
|
||||
# a subdirectory (a new entry in its parent). So after a scan we record every
|
||||
# library directory and its mtime; on the next scan we re-stat ONLY those
|
||||
# directories (a handful, vs 100k file ops). If none changed, the file set is
|
||||
# unchanged and the whole listing/stat pass is skipped.
|
||||
#
|
||||
# The one thing this cannot see is a file edited IN PLACE under the same name —
|
||||
# that bumps the file's mtime but not its directory's. That is rare for a song
|
||||
# library (you add and remove packs, you don't rewrite them under the same name),
|
||||
# and the manual Refresh forces a full scan (force=True) for exactly that case.
|
||||
def _dir_signature_file() -> Path:
|
||||
return appstate.config_dir / "scan_dir_signature.json"
|
||||
|
||||
|
||||
def _load_dir_signature() -> dict | None:
|
||||
try:
|
||||
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
|
||||
# Keyed by the DLC path so switching libraries never matches a stale
|
||||
# signature. Best-effort: a failed write just means the next scan is a full
|
||||
# one, never a wrong one.
|
||||
try:
|
||||
_dir_signature_file().write_text(
|
||||
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
|
||||
except OSError as e:
|
||||
log.debug("scan: could not persist dir signature: %s", e)
|
||||
|
||||
|
||||
def _library_dirs(all_songs, dlc: Path) -> set[str]:
|
||||
"""Every directory whose mtime reflects an add/remove of a library song:
|
||||
each song's containing directory and all of its ancestors up to the DLC
|
||||
root (the root itself always included, as "."). Derived from the already-
|
||||
listed songs — no extra filesystem walk. The builtin carve-outs
|
||||
(tutorials-builtin / minigames-builtin) are absent because the caller
|
||||
already excluded them from `all_songs`, so a minigame writing a drill there
|
||||
never invalidates the fast path.
|
||||
|
||||
Directory-form songs (loose-song folders, directory sloppak bundles) also
|
||||
record their OWN directory: a file added/removed/replaced INSIDE the folder
|
||||
bumps that folder's mtime but not its parent's, so tracking only the parent
|
||||
would miss an in-place change to such a song. File-form sloppaks (a single
|
||||
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
|
||||
stays at a handful of dir stats."""
|
||||
rels = {"."}
|
||||
for f in all_songs:
|
||||
rel = Path(_relpath(f, dlc))
|
||||
if f.is_dir():
|
||||
rels.add(rel.as_posix())
|
||||
parent = rel.parent
|
||||
rels.add(parent.as_posix())
|
||||
for anc in parent.parents:
|
||||
rels.add(anc.as_posix())
|
||||
return rels
|
||||
|
||||
|
||||
def _has_unextracted_columns() -> bool:
|
||||
"""True while any `songs` row still carries NULL in a column added by an
|
||||
additive migration — i.e. metadata the current extractor would fill but
|
||||
that no existing row has yet (currently `bass_tuning_name`).
|
||||
|
||||
The tree-signature fast path only asks "did the file set change"; on a
|
||||
settled library the answer is no forever, so a schema addition would never
|
||||
reach extraction. This one-row probe forces the full pass exactly until the
|
||||
backfill completes — `put()` writes '' rather than NULL, so it self-clears
|
||||
after the rescan instead of disabling the fast path permanently."""
|
||||
try:
|
||||
from metadata_db import MetadataDB
|
||||
cond = " OR ".join(f"{c} IS NULL" for c in MetadataDB._EXTRACTION_MARKER_COLS)
|
||||
row = appstate.meta_db.conn.execute(
|
||||
f"SELECT 1 FROM songs WHERE {cond} LIMIT 1").fetchone()
|
||||
except Exception as e:
|
||||
# A probe failure must not take the scan down; falling back to the fast
|
||||
# path costs at most a delayed backfill.
|
||||
log.debug("scan: unextracted-column probe failed: %s", e)
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
|
||||
def _record_dir_signature(all_songs, dlc: Path) -> None:
|
||||
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
|
||||
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
|
||||
_save_dir_signature(dlc, sig)
|
||||
|
||||
|
||||
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
|
||||
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
|
||||
unreadable — a vanished recorded dir means the tree changed, so fail to a
|
||||
full scan rather than a false match."""
|
||||
out: dict[str, int] = {}
|
||||
for rel in rels:
|
||||
try:
|
||||
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
|
||||
|
||||
|
||||
_scan_status = dict(_SCAN_STATUS_INIT)
|
||||
|
||||
# Mass-prune guard thresholds for automatic scans (not full rescan).
|
||||
# Refuse when would_remove >= max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * existing).
|
||||
# At 50 % of the library (or even a single row on tiny libraries) a sudden
|
||||
# disappearance almost certainly means a degraded mount, not a real deletion.
|
||||
_PRUNE_MAX_ABS = 1
|
||||
_PRUNE_MAX_FRAC = 0.5
|
||||
|
||||
|
||||
def _make_scan_executor():
|
||||
"""Build the executor for the background metadata scan.
|
||||
@@ -99,9 +220,17 @@ def _make_scan_executor():
|
||||
)
|
||||
|
||||
|
||||
def background_scan():
|
||||
def background_scan(force: bool = False, allow_mass_prune: bool = False):
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
`force` skips the directory-signature fast path and always does the full
|
||||
listing/stat pass — the manual Refresh sets it (see _dir_signature_file).
|
||||
|
||||
`allow_mass_prune` permits the scan to prune more than the catastrophic
|
||||
threshold (_PRUNE_MAX_FRAC of the library). Only set by /api/rescan/full
|
||||
(explicit user intent); automatic and plain /api/rescan scans leave it
|
||||
False so a degraded-mount partial listing can't silently wipe the library.
|
||||
|
||||
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||||
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
||||
terminal write cannot observe a stale False and start a second runner.
|
||||
@@ -121,6 +250,22 @@ def background_scan():
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
|
||||
|
||||
# Fast path: if every library directory recorded by the last scan still has
|
||||
# the same mtime, nothing was added, removed, or renamed, so the whole
|
||||
# glob-and-stat pass below can be skipped (see the signature comment above).
|
||||
# `force` (manual Refresh) always does the full pass. Seeding above is
|
||||
# idempotent — it only writes when a builtin is missing — so it does not
|
||||
# perturb the mtimes on a settled library.
|
||||
if not force and not _has_unextracted_columns():
|
||||
stored = _load_dir_signature()
|
||||
if stored is not None and stored.get("dlc") == str(dlc):
|
||||
current = _stat_dirs(dlc, stored["dirs"].keys())
|
||||
if current is not None and current == stored["dirs"]:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
|
||||
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
|
||||
len(current))
|
||||
return
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
# appearing to scan nothing.
|
||||
@@ -180,6 +325,43 @@ def background_scan():
|
||||
|
||||
current_files = {_relpath(f, dlc) for f in all_songs}
|
||||
|
||||
# Guard: refuse (or warn) when the listing suggests a degraded mount.
|
||||
# Two cases share the same logic:
|
||||
# 1. Zero listing — current_files empty, DB non-empty: would erase everything.
|
||||
# 2. Partial listing — current_files non-empty but so many DB rows are absent
|
||||
# that it looks like a mount glitch rather than deliberate deletions.
|
||||
# Threshold: would_remove >= max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * existing).
|
||||
# allow_mass_prune (only True for /api/rescan/full) lets the prune proceed with
|
||||
# a warning so the user's explicit intent is honoured even in the degraded case.
|
||||
with appstate.meta_db._lock:
|
||||
_existing = appstate.meta_db.conn.execute(
|
||||
"SELECT COUNT(*) FROM songs").fetchone()[0]
|
||||
|
||||
if _existing > 0:
|
||||
if not current_files:
|
||||
_would_remove = _existing
|
||||
else:
|
||||
with appstate.meta_db._lock:
|
||||
_db_files = {r[0] for r in appstate.meta_db.conn.execute(
|
||||
"SELECT filename FROM songs").fetchall()}
|
||||
_would_remove = len(_db_files - current_files)
|
||||
|
||||
_threshold = max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * _existing)
|
||||
if _would_remove >= _threshold:
|
||||
_msg = (
|
||||
f"Scan: would remove {_would_remove} of {_existing} DB rows "
|
||||
f"(threshold {int(_threshold)}) with only {len(current_files)} song(s) visible "
|
||||
"— possible mount/permission issue. Check the DLC mount; use Settings → "
|
||||
"Rescan Library (full) to authorise a large prune."
|
||||
)
|
||||
if allow_mass_prune:
|
||||
log.warning("%s — proceeding (user-authorised full rescan)", _msg)
|
||||
else:
|
||||
log.error("%s", _msg)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True,
|
||||
"stage": "error", "error": _msg}
|
||||
return
|
||||
|
||||
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
|
||||
# + genuinely-new files) so the scan can surface an added/removed summary.
|
||||
_delta = appstate.meta_db.delete_missing(current_files)
|
||||
@@ -209,6 +391,15 @@ def background_scan():
|
||||
cached = None
|
||||
if not cached:
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
elif any(cached.get(c) is None for c in appstate.meta_db._EXTRACTION_MARKER_COLS):
|
||||
# Row predates one of the per-perspective tuning columns (NULL
|
||||
# from the additive migration), so that perspective's tuning was
|
||||
# never extracted for it. Without this
|
||||
# re-queue an existing library would keep every bass column empty
|
||||
# forever — mtime/size still match, so nothing else would ever
|
||||
# bring the row back through extraction. Converges: put() always
|
||||
# writes '' (never NULL), so a rescanned row is never re-queued.
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
elif cached.get("arrangements") and any(
|
||||
"smart_name" not in a for a in cached["arrangements"]
|
||||
):
|
||||
@@ -223,6 +414,9 @@ def background_scan():
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
|
||||
if not to_scan:
|
||||
# Full pass completed with the DB already up to date — record the tree
|
||||
# signature so the next startup can take the fast path.
|
||||
_record_dir_signature(all_songs, dlc)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||||
return
|
||||
@@ -247,6 +441,9 @@ def background_scan():
|
||||
_scan_status["done"] += 1
|
||||
_scan_status["current"] = fname
|
||||
|
||||
# Record the tree signature after a completed full pass so the next startup
|
||||
# can skip it when nothing has changed.
|
||||
_record_dir_signature(all_songs, dlc)
|
||||
log.info("Scan complete: %d songs cached", len(to_scan))
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
|
||||
@@ -255,6 +452,13 @@ _scan_kick_lock = threading.Lock()
|
||||
|
||||
|
||||
_scan_rescan_pending = False
|
||||
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
|
||||
# manual Refresh bypasses the directory-signature fast path.
|
||||
_scan_force_next = False
|
||||
# Set by kick_scan(allow_mass_prune=True); allows the next pass to prune past the
|
||||
# catastrophic threshold. Sticky like _scan_force_next: if any queued request asks
|
||||
# for it, the follow-up pass honours it.
|
||||
_scan_mass_prune_next = False
|
||||
|
||||
|
||||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||||
@@ -265,9 +469,19 @@ _scan_rescan_pending = False
|
||||
_scan_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def kick_scan() -> bool:
|
||||
def kick_scan(force: bool = False, allow_mass_prune: bool = False) -> bool:
|
||||
"""Request a library rescan, single-flight + coalescing.
|
||||
|
||||
`force` skips the directory-signature fast path for the resulting pass (the
|
||||
manual Refresh uses it so an in-place same-name edit — the one thing the
|
||||
fast path can't see — is always picked up). A forced request that coalesces
|
||||
onto a running or queued scan keeps the force intent: the pass is forced if
|
||||
ANY pending request asked for it.
|
||||
|
||||
`allow_mass_prune` permits the resulting pass to prune past the catastrophic
|
||||
threshold. Sticky: if any pending request set it, the follow-up pass honours it.
|
||||
Only /api/rescan/full passes True — plain rescans and startup scans never do.
|
||||
|
||||
Returns True if a new scan thread was started, False if one was already
|
||||
running. In the latter case a follow-up pass is queued and runs as soon
|
||||
as the current scan finishes so files landing mid-scan (e.g. an upload
|
||||
@@ -275,8 +489,12 @@ def kick_scan() -> bool:
|
||||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||
into a single follow-up.
|
||||
"""
|
||||
global _scan_rescan_pending, _scan_thread
|
||||
global _scan_rescan_pending, _scan_thread, _scan_force_next, _scan_mass_prune_next
|
||||
with _scan_kick_lock:
|
||||
if force:
|
||||
_scan_force_next = True
|
||||
if allow_mass_prune:
|
||||
_scan_mass_prune_next = True
|
||||
if _scan_status["running"]:
|
||||
_scan_rescan_pending = True
|
||||
return False
|
||||
@@ -290,10 +508,17 @@ def kick_scan() -> bool:
|
||||
|
||||
def _scan_runner():
|
||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||
global _scan_rescan_pending
|
||||
global _scan_rescan_pending, _scan_force_next, _scan_mass_prune_next
|
||||
while True:
|
||||
# Consume both flags for THIS pass; requests queued mid-scan set them
|
||||
# again for the follow-up (sticky: any requester who asked for it wins).
|
||||
with _scan_kick_lock:
|
||||
forced = _scan_force_next
|
||||
_scan_force_next = False
|
||||
mass_prune = _scan_mass_prune_next
|
||||
_scan_mass_prune_next = False
|
||||
try:
|
||||
background_scan()
|
||||
background_scan(force=forced, allow_mass_prune=mass_prune)
|
||||
except Exception:
|
||||
log.exception("background scan failed unexpectedly")
|
||||
|
||||
|
||||
+59
-1
@@ -27,7 +27,11 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from song import compute_smart_names
|
||||
from tunings import tuning_name
|
||||
from tunings import (
|
||||
DEFAULT_PERSPECTIVE, PERSPECTIVES, ROLE_PERSPECTIVES, normalize_offsets,
|
||||
perspective_low_pitch, perspective_tuning_key, perspective_tuning_name,
|
||||
tuning_name,
|
||||
)
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
@@ -43,6 +47,56 @@ def _relpath(f: Path, dlc: Path) -> str:
|
||||
return f.name
|
||||
|
||||
|
||||
def _apply_role_tunings(meta: dict) -> None:
|
||||
"""Derive each ROLE perspective's tuning columns from the raw offsets the
|
||||
extractor emitted (currently bass + rhythm; guitar-lead reads the
|
||||
song-level columns the scanner has always written).
|
||||
|
||||
The domain rules live in `tunings` (see the PERSPECTIVES table and the
|
||||
block above it for the evidence behind each):
|
||||
|
||||
1. NORMALIZE FIRST. Stored bass arrays are commonly six elements whose
|
||||
last two slots are padding, so bass truncates to four strings before
|
||||
anything looks at them — padding must never reach the namer or the
|
||||
grouping key. Guitar does NOT truncate (a 7-string array is real).
|
||||
2. Refuse to name data the perspective distrusts (bass up-tuning), so the
|
||||
library can't send a player off to a tuning nobody plays.
|
||||
3. Group on CANONICAL PITCHES, not the raw offsets string — the same
|
||||
physical tuning serialized two ways must be ONE facet entry.
|
||||
|
||||
A song with no arrangement in that role gets EMPTY strings / 0, not NULL:
|
||||
'' is the indexed "we looked, there is no such chart" state the library's
|
||||
fallback keys on, while NULL means "never extracted" and re-scans.
|
||||
"""
|
||||
for persp in ROLE_PERSPECTIVES:
|
||||
raw = meta.pop(f"{persp.role}_tuning_offsets", None)
|
||||
offsets = normalize_offsets(raw, persp)
|
||||
if offsets is None:
|
||||
meta[persp.column("name")] = ""
|
||||
meta[persp.column("sort_key")] = 0
|
||||
meta[persp.column("offsets")] = ""
|
||||
meta[persp.column("key")] = ""
|
||||
meta[persp.column("low_pitch")] = None
|
||||
continue
|
||||
meta[persp.column("name")] = perspective_tuning_name(offsets, persp)
|
||||
meta[persp.column("sort_key")] = sum(offsets)
|
||||
# The NORMALIZED offsets are what we store: padding is not data, and a
|
||||
# client rendering target notes must not print phantom strings.
|
||||
meta[persp.column("offsets")] = " ".join(str(o) for o in offsets)
|
||||
meta[persp.column("key")] = perspective_tuning_key(offsets, persp)
|
||||
meta[persp.column("low_pitch")] = perspective_low_pitch(offsets, persp)
|
||||
|
||||
|
||||
def _apply_song_low_pitch(meta: dict, offsets: list[int]) -> None:
|
||||
"""Lowest open-string pitch of the SONG-level (guitar-lead) tuning, for
|
||||
the "playable without retuning" comparison. Indexed here, on the existing
|
||||
manifest-only pass — never by reopening chart JSON."""
|
||||
persp = PERSPECTIVES[DEFAULT_PERSPECTIVE]
|
||||
norm = normalize_offsets(offsets, persp)
|
||||
meta["tuning_low_pitch"] = (
|
||||
perspective_low_pitch(norm, persp) if norm is not None else None)
|
||||
|
||||
|
||||
def _extract_meta_sloppak(path: Path) -> dict:
|
||||
"""Extract metadata for a sloppak (file or directory)."""
|
||||
meta = sloppak_mod.extract_meta(path)
|
||||
@@ -52,6 +106,8 @@ def _extract_meta_sloppak(path: Path) -> dict:
|
||||
meta["tuning_name"] = name
|
||||
meta["tuning_sort_key"] = sum(offsets)
|
||||
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
||||
_apply_song_low_pitch(meta, offsets)
|
||||
_apply_role_tunings(meta)
|
||||
meta["format"] = "sloppak"
|
||||
# `extract_meta` already populates `stem_ids` (feedBack#129);
|
||||
# default to empty for older callers / mocks.
|
||||
@@ -86,6 +142,8 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
|
||||
meta["tuning_name"] = name
|
||||
meta["tuning_sort_key"] = sum(offsets)
|
||||
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
||||
_apply_song_low_pitch(meta, offsets)
|
||||
_apply_role_tunings(meta)
|
||||
meta["format"] = "loose"
|
||||
meta.setdefault("stem_ids", [])
|
||||
# The library helper exposes absolute filesystem paths for audio/art
|
||||
|
||||
+761
-114
File diff suppressed because it is too large
Load Diff
+58
-7
@@ -56,6 +56,13 @@ class Note:
|
||||
strum_group: int = -1
|
||||
scale_degree: int = -1
|
||||
ignore: bool = False
|
||||
# Keys hand assignment ('lh'/'rh', None = unassigned) — authored per-note,
|
||||
# e.g. from a MusicXML grand staff import in the editor. Lets the notation
|
||||
# hand split and hands-separate practice honor the author instead of the
|
||||
# mean-pitch heuristic. Distinct from `right_hand` (the bass plucking
|
||||
# finger); spelled-out `hand` on the wire because `rh` is taken.
|
||||
# Default-omitted on the wire; older readers ignore it.
|
||||
hand: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -175,6 +182,12 @@ class Arrangement:
|
||||
# `base`/`changes` drive the highway tone-change markers; `definitions`
|
||||
# feed the Tones plugin gear panel.
|
||||
tones: dict | None = None
|
||||
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335):
|
||||
# "bass" | "guitar" | "piano" | "keys" | "drums" | "". First-class DATA that
|
||||
# lets a user author an instrument on an arrangement whose NAME doesn't say
|
||||
# so. Lifted from the sloppak manifest entry by sloppak.load_song(); "" for
|
||||
# archive/loose sources, which instead carry the path_* flags below.
|
||||
type: str = ""
|
||||
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
|
||||
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
|
||||
path_lead: bool = False
|
||||
@@ -272,6 +285,10 @@ def note_to_wire(n: Note) -> dict:
|
||||
out["ch"] = n.strum_group
|
||||
if n.scale_degree != -1:
|
||||
out["sd"] = n.scale_degree
|
||||
# Keys hand assignment — default-omitted; validated on emit so a
|
||||
# directly-constructed Note can't put junk ('LH', True, …) on the wire.
|
||||
if n.hand in ("lh", "rh"):
|
||||
out["hand"] = n.hand
|
||||
return out
|
||||
|
||||
|
||||
@@ -492,8 +509,8 @@ def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
|
||||
O(notes) via ``arrangement_string_count`` — for a whole arrangement, hoist
|
||||
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
|
||||
per note instead."""
|
||||
is_bass = "bass" in (arr.name or "").lower()
|
||||
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
|
||||
base = base_open_string_midis(arrangement_string_count(arr),
|
||||
arrangement_is_bass(arr))
|
||||
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
|
||||
arr.tuning or [], note.string, note.fret)
|
||||
|
||||
@@ -532,6 +549,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
||||
strum_group=_wire_int_optional(d.get("ch"), -1),
|
||||
scale_degree=_wire_int_optional(d.get("sd"), -1),
|
||||
ignore=bool(d.get("ig", False)),
|
||||
# Keys hand assignment — strict enum decode: anything but 'lh'/'rh'
|
||||
# (junk, wrong case, bools) falls back to unassigned rather than
|
||||
# poisoning downstream hand-split/practice logic.
|
||||
hand=d.get("hand") if d.get("hand") in ("lh", "rh") else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -618,6 +639,23 @@ def phrase_from_wire(d: dict) -> Phrase:
|
||||
)
|
||||
|
||||
|
||||
def arrangement_is_bass(arr: Arrangement) -> bool:
|
||||
"""Whether ``arr`` is a bass, most-authoritative signal first: an
|
||||
editor-authored ``type == "bass"`` (feedpak-spec §5.2 / editor PR #335,
|
||||
lifted onto sloppaks by :func:`sloppak.load_song`), then the archive
|
||||
``path_bass`` <arrangementProperties> flag, then the legacy "bass"
|
||||
case-insensitive substring in the name. Single source of the bass decision
|
||||
so string-count derivation and the open-string pitch base (via
|
||||
:func:`base_open_string_midis`) agree — a bass authored on an arrangement
|
||||
whose NAME doesn't say "bass" must get both 4 lanes AND the bass MIDI base,
|
||||
not 4 lanes on a guitar octave."""
|
||||
return (
|
||||
(arr.type or "").strip().lower() == "bass"
|
||||
or bool(arr.path_bass)
|
||||
or "bass" in (arr.name or "").lower()
|
||||
)
|
||||
|
||||
|
||||
def arrangement_string_count(arr: Arrangement) -> int:
|
||||
"""Derive the active arrangement's string count.
|
||||
|
||||
@@ -635,10 +673,17 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
||||
But this is a LOWER BOUND only — a 6-string lead chart that
|
||||
never plays string 5 reports 5, undercounting by 1.
|
||||
|
||||
2. **Name-based fallback.** Arrangements named "Bass" (case-
|
||||
insensitive substring match) default to 4; everything else
|
||||
defaults to 6. This catches the partial-string-usage case
|
||||
where notes don't span all the instrument's strings.
|
||||
2. **Instrument-type fallback.** An arrangement whose authoritative
|
||||
instrument signal says bass defaults to 4; everything else
|
||||
defaults to 6. This catches the partial-string-usage case where
|
||||
notes don't span all the instrument's strings. The bass signal is
|
||||
any of: an editor-authored ``type == "bass"`` (feedpak-spec §5.2 /
|
||||
editor PR #335 — lifted onto sloppaks by ``sloppak.load_song``),
|
||||
the ``path_bass`` <arrangementProperties> flag (archive/DLC
|
||||
sources), or the legacy "bass" case-insensitive substring in the
|
||||
name. Trusting ``type``/``path_bass`` closes the gap where a user
|
||||
authors a bass instrument on an arrangement whose NAME doesn't say
|
||||
"bass" (the editor lays out 4 lanes; core must agree).
|
||||
|
||||
A third signal — ``len(arr.tuning)`` when it isn't the arrangement XML
|
||||
padded value of 6 — folds in for sloppak / GP-imported sources
|
||||
@@ -669,6 +714,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
||||
max(0, 4, 0) = 4
|
||||
* Empty arrangement named "Lead" (tuning len 6) →
|
||||
max(0, 6, 0) = 6
|
||||
* Editor-authored bass named "Low End" (type "bass", tuning len 6,
|
||||
notes 0..3) → name_based=4 → max(4, 4, 0) = 4
|
||||
* DLC bass named "Low End" (pathBass flag set, tuning len 6, notes
|
||||
0..3) → name_based=4 → max(4, 4, 0) = 4
|
||||
|
||||
Topkoa's issue argues plugins shouldn't do arrangement-name
|
||||
matching; server-side fallback IS the right place for it
|
||||
@@ -684,7 +733,9 @@ def arrangement_string_count(arr: Arrangement) -> int:
|
||||
if cn.string > max_s:
|
||||
max_s = cn.string
|
||||
notes_count = max_s + 1 if max_s >= 0 else 0
|
||||
name_based = 4 if "bass" in arr.name.lower() else 6
|
||||
# Bass signal (type / pathBass / name substring) — see arrangement_is_bass.
|
||||
# Any one being bass pulls the fallback to 4.
|
||||
name_based = 4 if arrangement_is_bass(arr) else 6
|
||||
# Tuning-length signal — only trustworthy when NOT the arrangement XML
|
||||
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
|
||||
# bass; length 7/8 indicates an extended-range guitar from GP.
|
||||
|
||||
+89
-3
@@ -1,4 +1,4 @@
|
||||
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
||||
"""Regenerate the runtime stylesheet over the full installed-plugin set.
|
||||
|
||||
Core's committed (and image-baked) stylesheet is built scanning only the
|
||||
in-tree plugins. A plugin installed at runtime — into ``FEEDBACK_PLUGINS_DIR``
|
||||
@@ -15,6 +15,7 @@ on a missing optional engine.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -40,12 +41,84 @@ _lock = threading.Lock()
|
||||
# in-flight build re-runs once more to pick up the newer plugin set instead of
|
||||
# every concurrent trigger stacking its own redundant build.
|
||||
_rerun = threading.Event()
|
||||
_fingerprint_cache: dict = {}
|
||||
|
||||
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
|
||||
# grandparent.
|
||||
APP_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _committed_css_fingerprint() -> str:
|
||||
"""Content hash of the SHIPPED stylesheet, cached on (mtime, size).
|
||||
|
||||
This is the marker that says WHICH CORE the runtime sheet was built against. Any change to
|
||||
core's CSS regenerates static/tailwind.min.css, which changes this hash.
|
||||
"""
|
||||
committed = APP_DIR / "static" / "tailwind.min.css"
|
||||
try:
|
||||
st = committed.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
key = (st.st_mtime_ns, st.st_size)
|
||||
cached = _fingerprint_cache.get("k")
|
||||
if cached == key:
|
||||
return _fingerprint_cache["v"]
|
||||
h = hashlib.sha256(committed.read_bytes()).hexdigest()
|
||||
_fingerprint_cache["k"] = key
|
||||
_fingerprint_cache["v"] = h
|
||||
return h
|
||||
|
||||
|
||||
def runtime_meta_path() -> Path:
|
||||
"""Sidecar recording which core the runtime sheet was built against."""
|
||||
return runtime_css_path().with_suffix(".meta.json")
|
||||
|
||||
|
||||
def runtime_css_is_current() -> bool:
|
||||
"""True when the runtime sheet was built against the core we are running NOW.
|
||||
|
||||
WHY NOT mtime. Codex [P2] on the second cut of #911, and it was right: filesystem
|
||||
timestamps are not a freshness signal across install methods. Archives and container images
|
||||
routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER mtime than
|
||||
a runtime sheet a user built days ago. The mtime comparison then reports the stale sheet as
|
||||
fresh and it masks the new core CSS indefinitely — permanently, if no Tailwind toolchain is
|
||||
present to trigger a rebuild.
|
||||
|
||||
Content answers the question timestamps only gesture at: the sidecar records the hash of the
|
||||
committed sheet this runtime build was made from. Core ships new CSS -> that file changes ->
|
||||
the hash changes -> the runtime sheet is correctly judged stale.
|
||||
"""
|
||||
try:
|
||||
meta = json.loads(runtime_meta_path().read_text())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return bool(meta.get("committed_sha256")) and meta["committed_sha256"] == _committed_css_fingerprint()
|
||||
|
||||
|
||||
def runtime_css_path() -> Path:
|
||||
"""Where the RUNTIME-augmented stylesheet is written.
|
||||
|
||||
NOT ``static/tailwind.min.css``. That file is a BUILD ARTEFACT: committed, image-baked,
|
||||
and generated by scanning only the in-tree plugins. This one is PER-INSTALL STATE — it
|
||||
additionally scans whatever the user has installed into FEEDBACK_PLUGINS_DIR, so it differs
|
||||
from machine to machine. They are different things and must not share a path.
|
||||
|
||||
Writing the runtime sheet over the committed one had two costs:
|
||||
|
||||
* IN A GIT CHECKOUT it silently modifies a TRACKED file. `git add -A` then sweeps a
|
||||
100KB reshuffle of minified CSS into the commit and `ci/tailwind-fresh` goes red with a
|
||||
diff that explains nothing. That is issue #911, and it cost a red run on a PR whose
|
||||
real diff touched no Tailwind classes at all.
|
||||
* IN A DEPLOY the app directory may be read-only. Writing app state into it is wrong on
|
||||
principle and fatal in practice.
|
||||
|
||||
CONFIG_DIR is where per-install state already lives.
|
||||
"""
|
||||
cfg = (getenv_compat("CONFIG_DIR", "") or "").strip()
|
||||
base = Path(cfg) if cfg else (Path.home() / ".local" / "share" / "feedback")
|
||||
return base / "tailwind.min.css"
|
||||
|
||||
|
||||
def _user_plugins_dir() -> Path | None:
|
||||
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
|
||||
if not raw:
|
||||
@@ -136,6 +209,14 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
cwd=str(APP_DIR), timeout=120,
|
||||
)
|
||||
os.replace(staged, out)
|
||||
# Stamp WHICH CORE this was built against. Without it, an upgraded app cannot tell a
|
||||
# current runtime sheet from one that predates its new CSS.
|
||||
try:
|
||||
runtime_meta_path().write_text(json.dumps({
|
||||
"committed_sha256": _committed_css_fingerprint(),
|
||||
}))
|
||||
except OSError:
|
||||
log.warning("tailwind: could not write the runtime sheet's meta sidecar")
|
||||
return True
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
stderr = (getattr(e, "stderr", "") or "")[-500:]
|
||||
@@ -153,7 +234,7 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
|
||||
|
||||
def rebuild(reason: str = "") -> bool:
|
||||
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
|
||||
"""Regenerate the RUNTIME stylesheet (see runtime_css_path) over baked-in + user plugins.
|
||||
|
||||
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
|
||||
Never raises — callers treat CSS freshness as best-effort. Concurrent
|
||||
@@ -166,8 +247,13 @@ def rebuild(reason: str = "") -> bool:
|
||||
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
|
||||
return False
|
||||
|
||||
out = APP_DIR / "static" / "tailwind.min.css"
|
||||
out = runtime_css_path()
|
||||
src = APP_DIR / "static" / "_tailwind.src.css"
|
||||
try:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
log.warning("tailwind rebuild skipped — cannot create %s%s", out.parent, tag)
|
||||
return False
|
||||
|
||||
# If a rebuild is already running, flag a rerun and return instead of
|
||||
# queueing a redundant build behind it.
|
||||
|
||||
+25
-9
@@ -32,20 +32,29 @@ def tokens(s: str) -> set[str]:
|
||||
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t}
|
||||
|
||||
|
||||
def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
|
||||
def sloppak_tone_changes(arr_tones) -> tuple[str, str, list[dict]]:
|
||||
"""Build the highway tone-change payload from an arrangement's tone block.
|
||||
|
||||
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
|
||||
returns ``(base, changes)`` where ``base`` is the initial tone name and
|
||||
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names,
|
||||
non-dict entries, and non-numeric / non-finite times are skipped — a
|
||||
hand-edited or third-party sloppak must not crash the highway WebSocket
|
||||
or emit NaN/inf (which the client's ``JSON.parse`` rejects).
|
||||
returns ``(base, base_rig, changes)`` where ``base`` is the initial tone
|
||||
name, ``base_rig`` is the ``rigs.json`` rig id bound to it (feedpak-spec
|
||||
§6.9; ``""`` when absent), and ``changes`` is a time-sorted
|
||||
``[{"t", "name", "rig"?}]`` list. Non-string names, non-dict entries, and
|
||||
non-numeric / non-finite times are skipped — a hand-edited or third-party
|
||||
sloppak must not crash the highway WebSocket or emit NaN/inf (which the
|
||||
client's ``JSON.parse`` rejects).
|
||||
|
||||
``rig`` / ``base_rig`` are carried through but NOT resolved against
|
||||
``rigs.json`` here: this builder only preserves the binding the chart
|
||||
declared. Realization selection and the ``intent.gm`` fallback (§7.9) belong
|
||||
to the consumer that actually voices the part.
|
||||
"""
|
||||
if not isinstance(arr_tones, dict):
|
||||
return "", []
|
||||
return "", "", []
|
||||
base_val = arr_tones.get("base", "")
|
||||
base = base_val.strip() if isinstance(base_val, str) else ""
|
||||
base_rig_val = arr_tones.get("base_rig", "")
|
||||
base_rig = base_rig_val.strip() if isinstance(base_rig_val, str) else ""
|
||||
|
||||
changes: list[dict] = []
|
||||
raw_changes = arr_tones.get("changes")
|
||||
@@ -65,6 +74,13 @@ def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
|
||||
continue
|
||||
if not math.isfinite(t):
|
||||
continue
|
||||
changes.append({"t": round(t, 3), "name": name})
|
||||
change = {"t": round(t, 3), "name": name}
|
||||
# ponytail: `rig` only when it's a usable id — a non-string or blank
|
||||
# value is dropped rather than forwarded, so a consumer can treat
|
||||
# presence of the key as "this change binds a rig".
|
||||
rig = c.get("rig")
|
||||
if isinstance(rig, str) and rig.strip():
|
||||
change["rig"] = rig.strip()
|
||||
changes.append(change)
|
||||
changes.sort(key=lambda x: x["t"])
|
||||
return base, changes
|
||||
return base, base_rig, changes
|
||||
|
||||
+243
-10
@@ -101,14 +101,16 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
||||
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||
non-numeric or non-positive (a provider could hand us anything)."""
|
||||
non-numeric, non-finite, or non-positive (a provider could hand us
|
||||
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
|
||||
500 the /api/tunings endpoint)."""
|
||||
out: list[int] = []
|
||||
for f in freqs:
|
||||
try:
|
||||
f = float(f)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if f <= 0:
|
||||
if not math.isfinite(f) or f <= 0:
|
||||
return None
|
||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||
return out
|
||||
@@ -414,27 +416,258 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
})
|
||||
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
|
||||
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
|
||||
# 7+-string community content falls through to the numeric fallback. See #43.
|
||||
# ── Bass tuning normalization (library indexing) ─────────────────────────────
|
||||
#
|
||||
# Bass charts in the wild store SIX-element tuning arrays even when the chart is
|
||||
# a 4-string part: slots 4-5 are PADDING. Confirmed by inspecting the charts
|
||||
# themselves — across every pack whose bass and guitar tunings diverge, no bass
|
||||
# note ever references string index 4 or 5 (the deepest reach is index 3).
|
||||
#
|
||||
# The feedpak spec carries NO string-count field (manifest `arrangement.tuning`
|
||||
# is an untyped integer array, `minItems: 1`), and counting strings for real
|
||||
# would mean parsing the 600KB-1.2MB arrangement JSON of every song on the
|
||||
# manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS
|
||||
# TO 4 STRINGS and truncate.
|
||||
#
|
||||
# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is
|
||||
# truncated to its low four. That is harmless for the overwhelmingly common
|
||||
# case — a 5-string in standard truncates to [0,0,0,0] and still names
|
||||
# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above.
|
||||
# Revisit if the spec ever gains a string count.
|
||||
BASS_DEFAULT_STRING_COUNT = 4
|
||||
|
||||
# Standard tunings (all six strings same offset)
|
||||
# Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights
|
||||
# string tension. Anything above +1 semitone across the board is data we do not
|
||||
# trust, not a tuning a human plays (the real-world example that motivated this
|
||||
# is a bass array of [5,5,5,5,4,4] — "all four strings up a perfect fourth" —
|
||||
# on a song whose guitar chart is dead standard and whose own note content is
|
||||
# consistent with standard tuning; the offsets were almost certainly computed
|
||||
# against a 6-string-bass reference with an uninitialised tail).
|
||||
#
|
||||
# Such a tuning MUST NOT be named: printing "A Standard" would send a player
|
||||
# off to retune to something nobody plays. It degrades to the custom path,
|
||||
# where it stays visible and distinct but makes no pitch claim.
|
||||
BASS_MAX_PLAUSIBLE_OFFSET = 1
|
||||
|
||||
|
||||
# ── Tuning PERSPECTIVES ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The library's tuning facet/filter/sort always answers for ONE arrangement
|
||||
# role. There are three, matching `active_instrument_profile`:
|
||||
#
|
||||
# guitar-lead the song-level (guitar-first) tuning — the historical
|
||||
# default. Its columns are the original unprefixed
|
||||
# `tuning_*` family, so today's behaviour is byte-identical.
|
||||
# guitar-rhythm the RHYTHM chart's own tuning. Lead and rhythm charts can
|
||||
# disagree (the same bug a bassist hit, inside guitar).
|
||||
# bass the BASS chart's own tuning.
|
||||
#
|
||||
# One table drives extraction, the derived columns, the SQL, and the labels —
|
||||
# rather than three near-identical column families maintained in parallel.
|
||||
class TuningPerspective:
|
||||
__slots__ = ("id", "role", "instrument", "string_count", "column_prefix",
|
||||
"truncate", "guard_up_tuning", "label")
|
||||
|
||||
def __init__(self, id, role, instrument, string_count, column_prefix,
|
||||
truncate, guard_up_tuning, label):
|
||||
self.id = id
|
||||
self.role = role # arrangement name to look for ('' = song-level)
|
||||
self.instrument = instrument
|
||||
self.string_count = string_count
|
||||
self.column_prefix = column_prefix # '' | 'rhythm_' | 'bass_'
|
||||
self.truncate = truncate
|
||||
self.guard_up_tuning = guard_up_tuning
|
||||
self.label = label
|
||||
|
||||
@property
|
||||
def instrument_key(self) -> str:
|
||||
return instrument_key(self.instrument, self.string_count)
|
||||
|
||||
def column(self, suffix: str) -> str:
|
||||
return f"{self.column_prefix}tuning_{suffix}"
|
||||
|
||||
|
||||
PERSPECTIVES: dict[str, TuningPerspective] = {
|
||||
"guitar-lead": TuningPerspective(
|
||||
"guitar-lead", "", "guitar", 6, "", False, False, "lead"),
|
||||
"guitar-rhythm": TuningPerspective(
|
||||
"guitar-rhythm", "rhythm", "guitar", 6, "rhythm_", False, False, "rhythm"),
|
||||
# Bass alone truncates (padded arrays) and guards against up-tuned data —
|
||||
# both are bass-specific findings, see the block above.
|
||||
"bass": TuningPerspective(
|
||||
"bass", "bass", "bass", BASS_DEFAULT_STRING_COUNT, "bass_", True, True, "bass"),
|
||||
}
|
||||
|
||||
DEFAULT_PERSPECTIVE = "guitar-lead"
|
||||
|
||||
# Perspectives that carry their OWN indexed columns (guitar-lead reads the
|
||||
# song-level ones, which the scanner has always written).
|
||||
ROLE_PERSPECTIVES = tuple(p for p in PERSPECTIVES.values() if p.column_prefix)
|
||||
|
||||
|
||||
def perspective(perspective_id) -> TuningPerspective:
|
||||
"""Resolve a perspective id, tolerating the legacy two-valued vocabulary
|
||||
('guitar' -> guitar-lead) and anything unknown (-> the default). An
|
||||
unrecognised value must never change filter semantics."""
|
||||
if perspective_id in PERSPECTIVES:
|
||||
return PERSPECTIVES[perspective_id]
|
||||
if perspective_id == "guitar":
|
||||
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
|
||||
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
|
||||
|
||||
|
||||
def normalize_offsets(offsets, persp: TuningPerspective) -> list[int] | None:
|
||||
"""Coerce a stored tuning array to the strings the perspective's
|
||||
instrument actually has. Returns None for anything unusable (empty /
|
||||
non-integer / too short), so callers leave the index empty rather than
|
||||
record a guess."""
|
||||
if not isinstance(offsets, list) or not offsets:
|
||||
return None
|
||||
if any(isinstance(o, bool) for o in offsets):
|
||||
return None
|
||||
try:
|
||||
vals = [int(o) for o in offsets]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(vals) < persp.string_count:
|
||||
return None
|
||||
# Only bass truncates: its arrays are padded (see above). A guitar array
|
||||
# longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would
|
||||
# invent a tuning the chart does not have.
|
||||
if persp.truncate:
|
||||
return vals[:persp.string_count]
|
||||
return vals
|
||||
|
||||
|
||||
def offsets_are_plausible(offsets: list[int], persp: TuningPerspective) -> bool:
|
||||
"""False for data the perspective refuses to trust — currently only the
|
||||
bass up-tuning guard (see BASS_MAX_PLAUSIBLE_OFFSET)."""
|
||||
if not persp.guard_up_tuning:
|
||||
return True
|
||||
return all(o <= BASS_MAX_PLAUSIBLE_OFFSET for o in offsets)
|
||||
|
||||
|
||||
def perspective_tuning_name(offsets: list[int], persp: TuningPerspective) -> str:
|
||||
"""Name a NORMALIZED tuning for this perspective, refusing to name data the
|
||||
perspective distrusts — that becomes "Custom Tuning", which stays distinct
|
||||
by its canonical pitches without asserting a tuning anyone plays."""
|
||||
if not offsets_are_plausible(offsets, persp):
|
||||
return "Custom Tuning"
|
||||
return tuning_name(offsets)
|
||||
|
||||
|
||||
def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str:
|
||||
"""CANONICAL grouping key: the tuning's absolute open-string pitches, so
|
||||
the same physical tuning groups as ONE facet entry no matter how it was
|
||||
serialized. Keyed on pitch rather than the raw offsets string, which is
|
||||
serialization-dependent and fragments.
|
||||
|
||||
Joined with ':' and NOT ',' — this key travels back as a `tunings` filter
|
||||
selector, and that query param is a COMMA-separated list, so a comma here
|
||||
would be split into meaningless fragments and match nothing.
|
||||
"""
|
||||
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
|
||||
if not midis:
|
||||
return ""
|
||||
return persp.id + ":" + ":".join(str(m) for m in midis)
|
||||
|
||||
|
||||
def perspective_low_pitch(offsets: list[int], persp: TuningPerspective) -> int | None:
|
||||
"""Absolute MIDI pitch of the tuning's LOWEST open string — the value the
|
||||
"playable without retuning" comparison is built on (see
|
||||
`chart_is_playable_in`)."""
|
||||
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
|
||||
if not midis:
|
||||
return None
|
||||
return min(midis)
|
||||
|
||||
|
||||
# ── "Playable without retuning" ──────────────────────────────────────────────
|
||||
#
|
||||
# What the player actually wants is "don't make me retune", not "match this
|
||||
# label". A chart is playable as-is when every pitch it needs is reachable on
|
||||
# the instrument as currently tuned.
|
||||
#
|
||||
# WHAT WE CAN HONESTLY COMPUTE. We index open-string TUNINGS, not the notes a
|
||||
# chart plays — note data lives in the 600KB-1.2MB arrangement JSON, and the
|
||||
# library scan is deliberately manifest-only, so we do not read it (indexing a
|
||||
# per-song lowest note would mean opening every chart on every scan).
|
||||
#
|
||||
# So the comparison is on OPEN-STRING PITCH, with a conservative assumption:
|
||||
# a chart may require its own lowest open string. That gives
|
||||
#
|
||||
# playable <=> your lowest open pitch <= the chart's lowest open pitch
|
||||
#
|
||||
# On a fretted instrument every pitch ABOVE your lowest open string is
|
||||
# reachable by fretting (strings sit within an octave of each other and the
|
||||
# neck gives ~2 octaves), so the low end is the binding constraint. This is
|
||||
# exactly the dominant real case: a 5-string bass (low B) plays every 4-string
|
||||
# standard chart AND every drop-D chart untouched, because the low D is just
|
||||
# fretted on the B string.
|
||||
#
|
||||
# DELIBERATE LIMITATIONS, both erring toward NOT claiming playability:
|
||||
# * A chart that never actually touches its lowest open string is excluded
|
||||
# anyway. Conservative: excluding a playable chart costs a scroll;
|
||||
# including an unplayable one costs a mid-practice retune, which is the
|
||||
# failure this feature exists to prevent.
|
||||
# * The UPPER bound is not checked — a chart tuned far above you could in
|
||||
# principle exceed your neck. Checking it needs the note range we do not
|
||||
# have. It is the rare direction (and the guard above already refuses
|
||||
# up-tuned bass data), but it is a real gap, not an oversight.
|
||||
def chart_is_playable_in(chart_low_pitch, your_low_pitch) -> bool:
|
||||
"""True when a chart whose lowest open string is `chart_low_pitch` needs no
|
||||
retune for a player tuned to `your_low_pitch`. Unknown chart pitch => False
|
||||
(never claim playability we cannot support)."""
|
||||
if chart_low_pitch is None or your_low_pitch is None:
|
||||
return False
|
||||
return int(your_low_pitch) <= int(chart_low_pitch)
|
||||
|
||||
|
||||
# Back-compat wrappers over the generic helpers — bass was the first
|
||||
# perspective and reads better spelled out at bass-specific call sites.
|
||||
def normalize_bass_offsets(offsets) -> list[int] | None:
|
||||
return normalize_offsets(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def bass_offsets_are_plausible(offsets: list[int]) -> bool:
|
||||
return offsets_are_plausible(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def bass_tuning_name(offsets: list[int]) -> str:
|
||||
return perspective_tuning_name(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def bass_tuning_key(offsets: list[int]) -> str:
|
||||
return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# The pattern checks below are gated on `len(offsets)` being 6 or 4. The
|
||||
# naming conventions are E-standard-rooted — e.g. a 7-string all-zeros
|
||||
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
|
||||
# 7+-string community content falls through to the numeric fallback (#43).
|
||||
#
|
||||
# Length 4 is accepted because a bass's open strings (EADG) are the low
|
||||
# four of the guitar, so the same standard/drop names apply at the same
|
||||
# offsets. Bass callers must normalize FIRST (`normalize_bass_offsets`):
|
||||
# stored bass arrays are commonly six elements with a padded tail, and the
|
||||
# padding must never reach this namer. See the block above.
|
||||
|
||||
# Standard tunings (all strings same offset)
|
||||
standard = {
|
||||
0: "E Standard", -1: "Eb Standard", -2: "D Standard",
|
||||
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
|
||||
-6: "Bb Standard", -7: "A Standard",
|
||||
1: "F Standard", 2: "F# Standard",
|
||||
}
|
||||
if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
|
||||
if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets):
|
||||
name = standard.get(offsets[0])
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Drop tunings (low string 2 semitones below the rest)
|
||||
# Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D)
|
||||
if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
|
||||
if len(offsets) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
|
||||
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
|
||||
low_note = note_names[offsets[0] % 12]
|
||||
return f"Drop {low_note}"
|
||||
|
||||
@@ -44,6 +44,13 @@ def run() -> None:
|
||||
# record — including early startup messages — passes through the same
|
||||
# structured pipeline.
|
||||
log_config=None,
|
||||
# Cap inbound WebSocket frames at the transport, before uvicorn
|
||||
# materializes them in memory (its default is 16 MB). No client sends
|
||||
# large frames to this server: the highway WS receives only small
|
||||
# control messages, and the /ws/sync relay enforces its own tighter
|
||||
# 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is
|
||||
# the defense-in-depth bound above it.
|
||||
ws_max_size=64 * 1024,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Generated
+964
-1
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -14,6 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"tailwindcss": "^3.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
Object.freeze({
|
||||
id: 'player-audio',
|
||||
label: 'Player and Audio Runtime',
|
||||
summary: 'Playback, renderer, mixer, monitoring, effects, and note-detection surfaces.',
|
||||
domains: Object.freeze(['playback', 'visualization', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
|
||||
summary: 'Playback, renderer, chart-transform, mixer, monitoring, effects, and note-detection surfaces.',
|
||||
domains: Object.freeze(['playback', 'visualization', 'chart-transform', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'plugin-defined',
|
||||
@@ -50,6 +50,7 @@
|
||||
'audio-monitoring': 'headphones',
|
||||
stems: 'sliders',
|
||||
'note-detection': 'activity',
|
||||
'chart-transform': 'box',
|
||||
diagnostics: 'fileSearch',
|
||||
pipeline: 'activity',
|
||||
'ui.navigation': 'list',
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
/* Career plugin — only what the prebuilt core Tailwind doesn't ship
|
||||
(plugin files are outside the core content glob, so responsive grid
|
||||
variants and cyan button shades live here under plugin-prefixed names). */
|
||||
|
||||
.career-venues {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.career-btn {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.career-btn-primary { background-color: #0891b2; color: #fff; }
|
||||
.career-btn-primary:hover { background-color: #06b6d4; }
|
||||
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
|
||||
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||
|
||||
.career-bar-track {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: rgba(31, 41, 55, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
.career-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #06b6d4;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.career-star-list {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.career-star-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.career-star-row .stars {
|
||||
color: #facc15;
|
||||
letter-spacing: 0.1em;
|
||||
min-width: 3.2em;
|
||||
}
|
||||
.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
|
||||
.career-star-row .song {
|
||||
color: #e5e7eb;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.career-star-row .song .artist { color: #9ca3af; }
|
||||
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
|
||||
.career-star-row .hint.close { color: #22d3ee; }
|
||||
|
||||
/* ── Passports (badge journey) ─────────────────────────────────────────── */
|
||||
|
||||
.career-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
|
||||
}
|
||||
.career-tab {
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.career-tab:hover { color: #e5e7eb; }
|
||||
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
|
||||
|
||||
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.pp-inst {
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: #d1d5db;
|
||||
background-color: rgba(31, 41, 55, 0.7);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
|
||||
.pp-inst.active { border-color: #06b6d4; color: #fff; }
|
||||
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
|
||||
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
|
||||
.pp-inst-plus { color: #6b7280; }
|
||||
|
||||
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||
subtle grain gradient (no image assets). Keep the hex pairs in sync with
|
||||
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
|
||||
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
||||
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
||||
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
||||
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
|
||||
|
||||
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
|
||||
.pp-cover, .pp-commit-cover {
|
||||
position: relative;
|
||||
width: 9.5rem;
|
||||
height: 13rem;
|
||||
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
|
||||
0 6px 16px rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
|
||||
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
|
||||
.pp-cover-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(240, 226, 195, 0.92);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pp-cover-inst {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(240, 226, 195, 0.55);
|
||||
}
|
||||
.pp-cover-sub {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
font-size: 0.6rem;
|
||||
color: rgba(240, 226, 195, 0.5);
|
||||
}
|
||||
|
||||
.pp-commit-card {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(55, 65, 81, 0.6);
|
||||
background-color: rgba(31, 41, 55, 0.35);
|
||||
}
|
||||
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
|
||||
|
||||
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
|
||||
.pp-brochure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
|
||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
|
||||
.pp-brochure-art { font-size: 1.4rem; }
|
||||
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
|
||||
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
|
||||
|
||||
/* The open book */
|
||||
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
|
||||
.pp-book-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(3, 7, 18, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pp-book {
|
||||
position: relative;
|
||||
width: min(92vw, 720px);
|
||||
height: min(72vh, 470px);
|
||||
perspective: 1800px;
|
||||
}
|
||||
.pp-page {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
background:
|
||||
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
|
||||
#efe6d0;
|
||||
color: #3f3428;
|
||||
padding: 1.1rem 1.2rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
|
||||
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
|
||||
.pp-book.open .pp-page-left { opacity: 1; }
|
||||
.pp-book-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
border-radius: 0 0.6rem 0.6rem 0;
|
||||
transform-origin: left center;
|
||||
transform: rotateY(0deg);
|
||||
backface-visibility: hidden;
|
||||
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
|
||||
.pp-book-close {
|
||||
position: absolute;
|
||||
top: -0.75rem;
|
||||
right: -0.75rem;
|
||||
z-index: 8;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
color: #d1d5db;
|
||||
border: 1px solid rgba(107, 114, 128, 0.5);
|
||||
}
|
||||
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
|
||||
.pp-page-head {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
|
||||
padding-bottom: 0.4rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The rubber stamp */
|
||||
.pp-stamp {
|
||||
--pp-rot: 0deg;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: 9rem;
|
||||
height: 9rem;
|
||||
border-radius: 999px;
|
||||
border: 3px solid #9a5b16;
|
||||
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
|
||||
color: #9a5b16;
|
||||
transform: rotate(var(--pp-rot));
|
||||
margin-top: 1.25rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
|
||||
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
|
||||
.pp-stamp-ghost {
|
||||
border-style: dashed;
|
||||
box-shadow: none;
|
||||
border-color: #b3a68b;
|
||||
color: #b3a68b;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.pp-stamp-hidden { opacity: 0; }
|
||||
.pp-stamp-mini {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-width: 2px;
|
||||
box-shadow: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2em;
|
||||
color: #d9a253;
|
||||
border-color: #d9a253;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
transform: rotate(var(--pp-rot));
|
||||
opacity: 0.95;
|
||||
}
|
||||
.pp-stamp-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
|
||||
filter: blur(5px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
|
||||
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
|
||||
@keyframes pp-slam {
|
||||
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
|
||||
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
|
||||
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
|
||||
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
|
||||
}
|
||||
@keyframes pp-ink {
|
||||
from { opacity: 0; transform: scale(0.6); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
|
||||
@keyframes pp-shake {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0); }
|
||||
25% { transform: translate(2px, 1px) rotate(0.3deg); }
|
||||
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
|
||||
75% { transform: translate(1px, -1px) rotate(0.15deg); }
|
||||
}
|
||||
|
||||
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
|
||||
.pp-invite { color: #6d5d40; }
|
||||
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
|
||||
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
|
||||
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
|
||||
.pp-drill.cleared { color: #4d7c0f; }
|
||||
|
||||
/* Ticket stubs */
|
||||
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
|
||||
.pp-stub {
|
||||
background: #f7f1e3;
|
||||
border: 1px solid #d8cbaa;
|
||||
border-left: 2px dashed #b6a98c;
|
||||
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
|
||||
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
|
||||
}
|
||||
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
|
||||
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
|
||||
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
|
||||
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
|
||||
|
||||
/* Wax-seal commitment ceremony */
|
||||
.pp-ceremony { width: 11rem; height: 15rem; }
|
||||
.pp-wax {
|
||||
position: absolute;
|
||||
bottom: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.25),
|
||||
0 3px 8px rgba(0, 0, 0, 0.55);
|
||||
color: rgba(255, 235, 235, 0.9);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
|
||||
}
|
||||
@keyframes pp-seal-drop {
|
||||
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
|
||||
60% { transform: translateY(0) scale(0.9); opacity: 1; }
|
||||
80% { transform: translateY(0) scale(1.05); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Small screens: the spread stacks; the flip cover would straddle both
|
||||
pages, so the book simply opens. */
|
||||
@media (max-width: 640px) {
|
||||
.pp-book { height: min(80vh, 620px); }
|
||||
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
|
||||
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
|
||||
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
|
||||
.pp-book-cover { display: none; }
|
||||
.pp-book { display: flex; flex-direction: column; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
|
||||
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
|
||||
.pp-slam, .pp-stamp-page::after { opacity: 1; }
|
||||
.pp-stamp-hidden { opacity: 0.92; }
|
||||
}
|
||||
|
||||
/* Badge ceremony (body-level overlay — shows over the player) */
|
||||
.pp-ceremony-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 220;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(3, 7, 18, 0.55);
|
||||
backdrop-filter: blur(1.5px);
|
||||
animation: pp-ceremony-in 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
|
||||
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.pp-ceremony-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-ceremony-stamp {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(239, 230, 208, 0.97);
|
||||
transform: rotate(var(--pp-rot)) scale(1.25);
|
||||
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
|
||||
margin-top: 0;
|
||||
}
|
||||
.pp-ceremony-stamp::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
|
||||
transform: translateX(-120%);
|
||||
animation: pp-shine 1.1s ease-out 0.75s forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes pp-shine {
|
||||
to { transform: translateX(120%); }
|
||||
}
|
||||
@keyframes pp-ceremony-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.pp-ceremony-title {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #f0e2c3;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
|
||||
|
||||
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
|
||||
.pp-hours {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8a7a5e;
|
||||
margin-top: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
|
||||
|
||||
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
|
||||
pointers only). */
|
||||
.pp-tilt {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
.pp-cover.pp-tilt {
|
||||
transform: perspective(700px)
|
||||
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||
rotate(var(--pp-cover-rot, 0deg));
|
||||
transition: transform 0.12s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
|
||||
.pp-stamp-page.pp-tilt {
|
||||
overflow: visible;
|
||||
transform: perspective(600px)
|
||||
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
|
||||
rotate(var(--pp-rot));
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.pp-tilt::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(105deg,
|
||||
transparent calc(var(--pp-glint-x, 50%) - 14%),
|
||||
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
|
||||
transparent calc(var(--pp-glint-x, 50%) + 14%));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-tilt:hover::after { opacity: 1; }
|
||||
|
||||
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
|
||||
.pp-stamp-ghost::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 7%;
|
||||
border-radius: 999px;
|
||||
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gold ink — a REAL gold badge (comb-verified improv). */
|
||||
.pp-stamp-gold {
|
||||
border-color: #b8860b;
|
||||
color: #a97b1b;
|
||||
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
|
||||
}
|
||||
.pp-stamp-mini.pp-stamp-gold {
|
||||
color: #f0c75e;
|
||||
border-color: #f0c75e;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Gold foil chip — rendered only alongside an earned gold stamp. */
|
||||
.pp-gold-foil {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.28rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #d9a253;
|
||||
color: #c89040;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
}
|
||||
.pp-gold-foil::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
|
||||
transform: translateX(-120%);
|
||||
animation: pp-foil 3.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pp-foil {
|
||||
0%, 55% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(120%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-gold-foil::after { animation: none; }
|
||||
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
|
||||
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
||||
.pp-tilt::after { display: none; }
|
||||
}
|
||||
|
||||
/* Practice invitations — closest stamps + bring-these-up */
|
||||
.pp-closest {
|
||||
border: 1px solid rgba(75, 85, 99, 0.45);
|
||||
border-radius: 0.6rem;
|
||||
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.pp-closest-head {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.pp-closest-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
|
||||
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
|
||||
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
|
||||
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
|
||||
|
||||
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-nearest-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-nearest-row em { color: #3f3428; }
|
||||
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
|
||||
|
||||
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.pp-wall-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
|
||||
.pp-wall-shelf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
|
||||
}
|
||||
.pp-wall-inst {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
min-width: 3.6rem;
|
||||
}
|
||||
.pp-wall-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: 4.2rem;
|
||||
height: 5.6rem;
|
||||
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
|
||||
0 3px 8px rgba(0, 0, 0, 0.4);
|
||||
padding: 0.3rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pp-wall-cover:hover { transform: translateY(-3px); }
|
||||
.pp-wall-cover span {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(240, 226, 195, 0.9);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-wall-cover em {
|
||||
font-size: 0.42rem;
|
||||
letter-spacing: 0.22em;
|
||||
font-style: normal;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
|
||||
.pp-wall-link {
|
||||
align-self: flex-end;
|
||||
font-size: 0.72rem;
|
||||
color: #22d3ee;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
.pp-wall-link:hover { text-decoration: underline; }
|
||||
|
||||
/* The home-page career card — a trading card among stat tiles. */
|
||||
.pp-dash-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(217, 162, 83, 0.35);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
|
||||
linear-gradient(160deg, #2b1414, #17111c);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
|
||||
.pp-dash-shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
|
||||
transform: translateX(-130%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
|
||||
.pp-dash-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
|
||||
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
|
||||
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
|
||||
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
|
||||
.pp-dash-ask em { color: #cbd5e1; }
|
||||
|
||||
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: none; }
|
||||
.pp-wall-cover, .pp-dash-card { transition: none; }
|
||||
}
|
||||
|
||||
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
|
||||
|
||||
.pp-poster {
|
||||
position: relative;
|
||||
width: min(92vw, 420px);
|
||||
padding: 2rem 1.6rem 1.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(180deg, #141019, #241318);
|
||||
border: 2px solid rgba(217, 162, 83, 0.45);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
|
||||
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
|
||||
.pp-poster-title {
|
||||
color: #d9a253;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
|
||||
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
|
||||
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
|
||||
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
|
||||
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
|
||||
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
|
||||
.pp-poster-summary { cursor: default; }
|
||||
|
||||
.pp-gig-strip {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
|
||||
background: rgba(10, 8, 14, 0.85);
|
||||
border: 1px solid rgba(217, 162, 83, 0.4);
|
||||
border-radius: 999px;
|
||||
color: rgba(240, 226, 195, 0.85);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
|
||||
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
|
||||
|
||||
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-giglog-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
|
||||
|
||||
/* Tuning preference pills on gig poster */
|
||||
.pp-tuning-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.5rem 0 0.3rem; }
|
||||
.pp-tuning-pill { font-size: 0.65rem; padding: 0.2rem 0.6rem; border-radius: 999px; opacity: 0.7; }
|
||||
.pp-tuning-pill-on { opacity: 1; border-color: #d9a253; color: #d9a253; }
|
||||
.pp-tuning-select { font-size: 0.7rem; background: #1a1510; color: #c8b48a; border: 1px solid rgba(138,122,94,0.5); border-radius: 4px; padding: 0.15rem 0.4rem; }
|
||||
.pp-poster-tuning-chip { font-size: 0.6rem; color: #8a7a5e; margin-left: 0.35rem; }
|
||||
|
||||
/* Gig interstitial — tune up banner */
|
||||
.pp-gig-strip.pp-interstitial { pointer-events: auto; display: flex; align-items: center; gap: 0.75rem; border-radius: 8px; border-color: rgba(64,128,224,0.5); }
|
||||
.pp-gig-tune-label { color: #4080e0; letter-spacing: 0.08em; font-size: 0.78rem; }
|
||||
.pp-gig-start-btn { font-size: 0.7rem; padding: 0.25rem 0.7rem; }
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"badge_requirement": {
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"gig": {
|
||||
"min_songs": 3,
|
||||
"max_songs": 5,
|
||||
"stakes_songs": 2,
|
||||
"encore_accuracy": 0.75
|
||||
},
|
||||
"families": [
|
||||
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
||||
{ "key": "blues", "match": ["blues"] },
|
||||
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
|
||||
{ "key": "funk", "match": ["funk", "disco"] },
|
||||
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
|
||||
],
|
||||
"genres": {
|
||||
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
|
||||
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
|
||||
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
|
||||
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
|
||||
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
|
||||
},
|
||||
"drill_labels": {
|
||||
"blues_shuffle": "Blues Shuffle",
|
||||
"rock_power_backbeat": "Power Chords & Backbeat",
|
||||
"melodic_metal_gallop": "Gallop Picking",
|
||||
"sixteenth_pocket": "16th Pocket",
|
||||
"vl_shells": "Shell Voicings"
|
||||
},
|
||||
"graded_instruments": [
|
||||
"guitar",
|
||||
"keys"
|
||||
],
|
||||
"instruments": [
|
||||
"guitar",
|
||||
"bass",
|
||||
"keys",
|
||||
"drums"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "career",
|
||||
"name": "Career",
|
||||
"version": "0.2.0",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
|
||||
"screen": "screen.html",
|
||||
"script": "screen.js",
|
||||
"styles": "assets/career.css",
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"server_files": [
|
||||
"career/"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
<div class="max-w-5xl mx-auto px-4 py-6">
|
||||
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<h1 class="text-2xl font-bold text-white">Career</h1>
|
||||
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
|
||||
</div>
|
||||
<div class="career-tabs" role="tablist">
|
||||
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
|
||||
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
|
||||
</div>
|
||||
|
||||
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
|
||||
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
|
||||
<div id="career-progress-wrap" class="mb-6">
|
||||
<div class="career-bar-track">
|
||||
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
|
||||
</div>
|
||||
<div id="career-venues" class="career-venues"></div>
|
||||
<div class="mt-8">
|
||||
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
|
||||
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
|
||||
<div id="career-star-summary" class="text-xs text-gray-400"></div>
|
||||
</div>
|
||||
<div id="career-star-list" class="career-star-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
|
||||
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
|
||||
<div id="pp-instruments" class="pp-instruments"></div>
|
||||
<div id="pp-closest" class="mt-4"></div>
|
||||
<div id="pp-shelf-wrap" class="mt-5">
|
||||
<div id="pp-shelf" class="pp-shelf"></div>
|
||||
</div>
|
||||
<div id="pp-rack-wrap" class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
|
||||
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
|
||||
<div id="pp-rack" class="pp-rack"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pp-overlay" class="pp-overlay hidden"></div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
|
||||
settings.server_files has a visible home in Settings; nothing to configure. -->
|
||||
<div class="text-sm text-gray-300 space-y-2">
|
||||
<p><strong>Career</strong> computes stars and genre badges from your play
|
||||
stats — they are never stored, so there is nothing to back up or reset.</p>
|
||||
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
|
||||
commitments, opened genre passports, and the practice-drill snapshot the
|
||||
Virtuoso plugin reports. These ride along in
|
||||
<em>Settings → Export</em> automatically.</p>
|
||||
</div>
|
||||
<hr class="border-gray-800 my-3">
|
||||
<div class="space-y-3 text-sm">
|
||||
<label class="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
|
||||
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
|
||||
</span>
|
||||
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
|
||||
</label>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var KEY = 'feedBack-venue-crowd-sfx';
|
||||
var box = document.getElementById('career-sfx-toggle');
|
||||
if (!box) return;
|
||||
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
|
||||
box.addEventListener('change', function () {
|
||||
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
@@ -0,0 +1,249 @@
|
||||
// Passport UI pure-logic tests: load screen.js in a bare vm window and
|
||||
// exercise the __careerPassportTest seam (no DOM beyond stubs, 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(seed) {
|
||||
const store = Object.assign({}, seed);
|
||||
const window = {
|
||||
console,
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => {},
|
||||
},
|
||||
notifications: [],
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
window.fbNotify = { show: (n) => window.notifications.push(n) };
|
||||
const context = vm.createContext(window);
|
||||
// `document` and `localStorage` resolve as bare names inside the IIFE.
|
||||
context.document = window.document;
|
||||
context.localStorage = window.localStorage;
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'career/screen.js' });
|
||||
return window;
|
||||
}
|
||||
|
||||
test('module loads (and boots) in a bare vm window', () => {
|
||||
const w = load();
|
||||
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
|
||||
});
|
||||
|
||||
test('ppKey normalizes case and whitespace', () => {
|
||||
const { ppKey } = load().__careerPassportTest;
|
||||
assert.equal(ppKey(' Blues Rock '), 'blues rock');
|
||||
assert.equal(ppKey('FUNK'), 'funk');
|
||||
assert.equal(ppKey(''), '');
|
||||
assert.equal(ppKey(null), '');
|
||||
});
|
||||
|
||||
test('ppJitter is deterministic and bounded', () => {
|
||||
const { ppJitter } = load().__careerPassportTest;
|
||||
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
|
||||
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
|
||||
const j = ppJitter(seed, 8);
|
||||
assert.ok(j >= -8 && j <= 8, `${seed} → ${j}`);
|
||||
}
|
||||
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
|
||||
});
|
||||
|
||||
test('detectNewBadges notifies once per badge, never after it is seen', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
const view = {
|
||||
instruments: {
|
||||
guitar: {
|
||||
passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
|
||||
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
assert.match(w.notifications[0].message, /Blues/);
|
||||
// Same view again in the same session: no duplicate notification.
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// Seen (slam played) → a fresh session stays quiet too.
|
||||
t.markBadgeSeen('guitar', 'blues');
|
||||
// JSON-compare: vm objects carry a foreign Object prototype.
|
||||
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
|
||||
|
||||
// Fresh session (new vm, empty notify cache) with the badge already seen:
|
||||
// detection must stay silent.
|
||||
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a new badge triggers the crowd celebrate() exactly once', () => {
|
||||
const w = load();
|
||||
let calls = 0;
|
||||
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
w.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(calls, 1);
|
||||
// Same session, same view: no re-celebration.
|
||||
w.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('ceremony degrades when the crowd layer is absent or throws', () => {
|
||||
const w = load();
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
|
||||
w.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// celebrate() throwing must not break detection.
|
||||
const w2 = load();
|
||||
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 1);
|
||||
});
|
||||
|
||||
test('seenBadges tolerates corrupt stored values', () => {
|
||||
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
|
||||
const w = load({ 'feedBack-career-badges-seen': bad });
|
||||
const t = w.__careerPassportTest;
|
||||
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
|
||||
// And detection still works on top of the recovered empty state.
|
||||
t.detectNewBadges({ instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
|
||||
assert.equal(w.notifications.length, 1, `stored ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
|
||||
const { fmtHours } = load().__careerPassportTest;
|
||||
assert.equal(fmtHours(0), '');
|
||||
assert.equal(fmtHours(59), '');
|
||||
assert.equal(fmtHours(60), '1 min');
|
||||
assert.equal(fmtHours(1800), '30 min');
|
||||
assert.equal(fmtHours(3600), '1 h');
|
||||
assert.equal(fmtHours(51120), '14.2 h');
|
||||
assert.equal(fmtHours(null), '');
|
||||
assert.equal(fmtHours('junk'), '');
|
||||
});
|
||||
|
||||
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
||||
const { ppFillFraction } = load().__careerPassportTest;
|
||||
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
|
||||
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
|
||||
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
|
||||
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
|
||||
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
|
||||
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
|
||||
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
||||
assert.equal(ppFillFraction(null), 0);
|
||||
});
|
||||
|
||||
test('careerTotals / wall + dash card stay absent without commitment', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
// No _pp at all → null; committed-less view → null (absent-not-empty).
|
||||
assert.equal(t.careerTotals(), null);
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: null, passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed but zero passports opened: still absent (no zero-wall).
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 'x', passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed with an earned badge + hours → totals aggregate.
|
||||
t.setView({ config: { instruments: ['guitar', 'bass'] },
|
||||
instruments: {
|
||||
guitar: { committed_at: 'x', passports: [
|
||||
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
|
||||
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
|
||||
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
|
||||
bass: { committed_at: null, passports: [] },
|
||||
} });
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
assert.equal(totals.seconds, 3720);
|
||||
assert.equal(totals.walls.length, 1);
|
||||
});
|
||||
|
||||
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
let remaining = 1;
|
||||
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
|
||||
t.setGigRun({
|
||||
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
|
||||
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
|
||||
});
|
||||
// First song ends, one remains → the strip advances, no completion.
|
||||
t.onGigSongEnded();
|
||||
assert.equal(t.getGigRun().idx, 1);
|
||||
// Stop while the queue is still active (end-of-song teardown) → run survives.
|
||||
t.onGigSongStop();
|
||||
assert.notEqual(t.getGigRun(), null);
|
||||
// User quits: queue cleared → stop with a dead queue abandons (no log).
|
||||
remaining = 0;
|
||||
t.onGigSongStop();
|
||||
assert.equal(t.getGigRun(), null);
|
||||
});
|
||||
|
||||
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
|
||||
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
|
||||
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||
const t = w.__careerPassportTest;
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
assert.match(w.notifications[0].title, /Gold/);
|
||||
// Same session: no duplicate.
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// Gold slam seen → fresh session stays silent.
|
||||
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
|
||||
assert.equal(seen['guitar/blues@gold'], 1);
|
||||
assert.equal(seen['guitar/blues'], 1);
|
||||
// A later view where the badge reads 'earned' (e.g. gold state lost
|
||||
// server-side) must not replay the bronze ceremony.
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('careerTotals counts gold badges on the wall', () => {
|
||||
const t = load().__careerPassportTest;
|
||||
t.setView({
|
||||
config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
|
||||
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
|
||||
] } },
|
||||
});
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
assert.equal(totals.walls[0].earned[0].badge, 'gold');
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"venue": "arena",
|
||||
"version": 1,
|
||||
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
|
||||
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
|
||||
"intro": {"video": "intro.mp4", "audio": "arena-ambience.mp3"},
|
||||
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"venue": "bar",
|
||||
"version": 1,
|
||||
"loops": {
|
||||
"bored": "bored.mp4",
|
||||
"neutral": "neutral.mp4",
|
||||
"engaged": "engaged.mp4",
|
||||
"ecstatic": "ecstatic.mp4"
|
||||
},
|
||||
"stingers": {
|
||||
"clap": "clap.mp4",
|
||||
"cheer": "cheer.mp4"
|
||||
},
|
||||
"intro": {
|
||||
"video": "intro.mp4",
|
||||
"audio": "bar-ambience.mp3"
|
||||
},
|
||||
"sfx": {
|
||||
"up": "sfx-up.mp3",
|
||||
"down": "sfx-down.mp3"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"venue": "club",
|
||||
"version": 1,
|
||||
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
|
||||
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
|
||||
"intro": {"video": "intro.mp4", "audio": "club-ambience.mp3"},
|
||||
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"star_accuracy_thresholds": [
|
||||
0.6,
|
||||
0.75,
|
||||
0.85
|
||||
],
|
||||
"venues": [
|
||||
{
|
||||
"id": "bar",
|
||||
"name": "The Dive Bar",
|
||||
"description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.",
|
||||
"star_threshold": 0,
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "club",
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": {
|
||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"bytes": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "arena",
|
||||
"name": "Feedback Arena",
|
||||
"description": "Ten thousand seats. Try not to think about it.",
|
||||
"star_threshold": 150,
|
||||
"pack": {
|
||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
|
||||
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
|
||||
"bytes": 351284599
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -878,6 +878,146 @@ function createFolderSurface(cfg) {
|
||||
var _dragRafId = null;
|
||||
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
||||
|
||||
// ── Windowed song lists ─────────────────────────────────────────────
|
||||
// A song list used to render EVERY song it held. On a flat 50,944-song
|
||||
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
|
||||
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
|
||||
// even be looking at. It also poisons unrelated code: any
|
||||
// `document.querySelector` miss anywhere in the app must walk that whole
|
||||
// tree, which is how song_preview's per-frame menu check ended up eating
|
||||
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
|
||||
//
|
||||
// So render only what is on screen. Rows are uniform height (and grid cards
|
||||
// uniform size), so the window is pure arithmetic — no per-row observers.
|
||||
// Off-window rows are represented by padding on the list itself rather than
|
||||
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
|
||||
// shift the columns, whereas padding works identically for both layouts.
|
||||
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
|
||||
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
|
||||
var _virtualCleanups = [];
|
||||
var _virtualLists = []; // repaint fns, one per live windowed list
|
||||
|
||||
// Which slice of the list is on screen. Pure arithmetic — kept separate from
|
||||
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
|
||||
//
|
||||
// top : list's offset relative to the scroller viewport's top. NEGATIVE
|
||||
// once the user has scrolled the list's start above the fold.
|
||||
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
|
||||
//
|
||||
// Returns the song index range [start, end) to render, plus how many ROWS of
|
||||
// padding stand in for the songs above and below it.
|
||||
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
|
||||
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
|
||||
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
|
||||
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
|
||||
// Scrolled entirely past the list (either direction): keep one row alive
|
||||
// rather than emptying it, so the padding math stays anchored.
|
||||
if (lastRow <= firstRow) {
|
||||
firstRow = Math.min(firstRow, rows - 1);
|
||||
lastRow = firstRow + 1;
|
||||
}
|
||||
return {
|
||||
start: firstRow * perRow,
|
||||
end: Math.min(total, lastRow * perRow),
|
||||
padRowsTop: firstRow,
|
||||
padRowsBottom: Math.max(0, rows - lastRow),
|
||||
};
|
||||
}
|
||||
|
||||
function _clearVirtualLists() {
|
||||
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||
_virtualCleanups = [];
|
||||
_virtualLists = [];
|
||||
}
|
||||
|
||||
// Fill `list` with `songs`, windowed when the list is big enough to matter.
|
||||
// `make(song)` builds one row/card.
|
||||
function _fillSongList(list, songs, make) {
|
||||
var sorted = _sortSongs(songs);
|
||||
if (sorted.length <= VIRTUAL_MIN) {
|
||||
sorted.forEach(function (s) { list.appendChild(make(s)); });
|
||||
return;
|
||||
}
|
||||
|
||||
var scroller = _getScrollEl();
|
||||
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
|
||||
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
|
||||
|
||||
// Measure one real row once — no hardcoded row height to drift out of
|
||||
// sync with the CSS. (The list is shown before it is populated, so this
|
||||
// measures a laid-out row, not a zero-height one.)
|
||||
var probe = make(sorted[0]);
|
||||
probe.style.visibility = 'hidden';
|
||||
list.appendChild(probe);
|
||||
var probeRect = probe.getBoundingClientRect();
|
||||
var rowH = probeRect.height || 44;
|
||||
var cardW = probeRect.width || 150;
|
||||
list.removeChild(probe);
|
||||
|
||||
var GRID_GAP = 12; // matches the grid's `gap:12px`
|
||||
var raf = 0, lastStart = -1, lastEnd = -1;
|
||||
|
||||
// Recomputed on EVERY paint, not captured once: a window resize changes
|
||||
// the grid's column count, and therefore the row count and the height of
|
||||
// the padding standing in for off-window rows. paint() runs on resize, so
|
||||
// stale metrics would slice the wrong songs and mis-size the list.
|
||||
function metrics() {
|
||||
var perRow = 1, itemH = rowH;
|
||||
if (_view === 'grid') {
|
||||
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
|
||||
itemH = rowH + GRID_GAP;
|
||||
}
|
||||
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
|
||||
}
|
||||
|
||||
function paint() {
|
||||
raf = 0;
|
||||
// Collapsed (display:none) or detached: nothing to paint, and don't
|
||||
// pay for layout on every scroll tick of a section nobody can see.
|
||||
// Forget the last window so re-showing repaints from scratch against
|
||||
// the new position rather than short-circuiting on a stale memo.
|
||||
if (!list.isConnected || list.offsetParent === null) {
|
||||
lastStart = -1; lastEnd = -1;
|
||||
return;
|
||||
}
|
||||
var m = metrics();
|
||||
// Where the list sits relative to the scroller's viewport.
|
||||
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
var vh = scroller.clientHeight || window.innerHeight;
|
||||
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
|
||||
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
|
||||
lastStart = w.start; lastEnd = w.end;
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
|
||||
list.textContent = '';
|
||||
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
|
||||
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
|
||||
list.appendChild(frag);
|
||||
}
|
||||
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
|
||||
|
||||
scroller.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule);
|
||||
// Expanding or collapsing ANY section moves every list below it. Those
|
||||
// lists' windows are computed from their position, so they must repaint
|
||||
// too — otherwise they keep the window from their old position and show
|
||||
// blank padding where songs should be until the user happens to scroll.
|
||||
_virtualLists.push(schedule);
|
||||
_virtualCleanups.push(function () {
|
||||
scroller.removeEventListener('scroll', schedule);
|
||||
window.removeEventListener('resize', schedule);
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
});
|
||||
paint();
|
||||
}
|
||||
|
||||
// Re-window every live list — call after anything that can move them
|
||||
// vertically (a folder expanding/collapsing, a section being shown).
|
||||
function _repaintVirtualLists() {
|
||||
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||
}
|
||||
|
||||
function _getScrollEl() {
|
||||
var el = _treeEl();
|
||||
while (el && el !== document.documentElement) {
|
||||
@@ -1159,8 +1299,8 @@ function createFolderSurface(cfg) {
|
||||
|
||||
var _listPopulated = open;
|
||||
function _populateList() {
|
||||
_sortSongs(folder.songs).forEach(function (s) {
|
||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
||||
_fillSongList(list, folder.songs, function (s) {
|
||||
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||
});
|
||||
(folder.children || []).forEach(function (child) {
|
||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||
@@ -1195,12 +1335,18 @@ function createFolderSurface(cfg) {
|
||||
hdr.addEventListener('click', function () {
|
||||
if (_query()) return;
|
||||
var nowOpen = content.style.display === 'none';
|
||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||
// Show BEFORE populating: a windowed list measures a real row and the
|
||||
// scroller viewport, and both are zero while display:none.
|
||||
content.style.display = nowOpen ? '' : 'none';
|
||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
||||
if (nowOpen) _openFolders.add(folder.path);
|
||||
else _openFolders.delete(folder.path);
|
||||
_storeJSON('open', [..._openFolders]);
|
||||
// This toggle moved everything below it — re-window the other lists,
|
||||
// and re-window THIS one if it was already populated (its saved
|
||||
// window was computed at its old position).
|
||||
_repaintVirtualLists();
|
||||
});
|
||||
|
||||
wrap.appendChild(hdr); wrap.appendChild(content);
|
||||
@@ -1245,8 +1391,8 @@ function createFolderSurface(cfg) {
|
||||
}
|
||||
var _populated = _unsortedOpen;
|
||||
function _populate() {
|
||||
_sortSongs(songs).forEach(function (s) {
|
||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
||||
_fillSongList(list, songs, function (s) {
|
||||
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||
});
|
||||
}
|
||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||
@@ -1255,10 +1401,12 @@ function createFolderSurface(cfg) {
|
||||
hdr.addEventListener('click', function () {
|
||||
if (_query()) return;
|
||||
_unsortedOpen = list.style.display === 'none';
|
||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||
// Show BEFORE populating — see the folder toggle above.
|
||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
||||
_store(cfg.unsortedKey, String(_unsortedOpen));
|
||||
_repaintVirtualLists(); // this toggle moved every list below it
|
||||
});
|
||||
|
||||
wrap.appendChild(hdr); wrap.appendChild(list);
|
||||
@@ -1340,6 +1488,10 @@ function createFolderSurface(cfg) {
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
function _render() {
|
||||
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
||||
// Drop the scroll listeners of the previous render's windowed lists —
|
||||
// their `list` nodes are about to be detached, and a surviving listener
|
||||
// would keep painting into orphaned DOM (and leak on every re-render).
|
||||
_clearVirtualLists();
|
||||
var treeEl = _treeEl();
|
||||
if (!treeEl) return;
|
||||
var data = _filtered();
|
||||
@@ -1451,6 +1603,7 @@ function createFolderSurface(cfg) {
|
||||
|
||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||
function _unload() {
|
||||
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||
if (!cfg.searchInputId) return;
|
||||
var el = _el(cfg.searchInputId);
|
||||
if (el) el.style.maxWidth = '';
|
||||
@@ -1554,6 +1707,8 @@ function createFolderSurface(cfg) {
|
||||
init: _init,
|
||||
onScreenChanged: _onScreenChanged,
|
||||
render: _render,
|
||||
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1656,6 +1811,7 @@ if (!window.__folderLibraryLib) {
|
||||
window.folderLibrary = {
|
||||
load: function (force) { return _lib.load(force); },
|
||||
unload: function () { _lib.unload(); },
|
||||
__test: _lib.__test,
|
||||
};
|
||||
|
||||
// Auto-load if folder view was already active when this script was injected.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Windowed song lists (feedBack#965).
|
||||
//
|
||||
// A song list used to render EVERY song. On a flat 50,944-song library that is
|
||||
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
|
||||
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
|
||||
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
|
||||
// the app had to walk that whole tree.
|
||||
//
|
||||
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
|
||||
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
|
||||
// place, so it is tested directly — the DOM glue around it is not the risky bit.
|
||||
|
||||
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,
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
getElementById() { return null; },
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||
},
|
||||
addEventListener() {},
|
||||
localStorage: { getItem() { return null; }, setItem() {} },
|
||||
performance: { now: () => 0 },
|
||||
setInterval() { return 0; },
|
||||
clearInterval() {},
|
||||
requestAnimationFrame() { return 0; },
|
||||
cancelAnimationFrame() {},
|
||||
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||
innerHeight: 800,
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const ctx = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||
return window.folderLibrary.__test;
|
||||
}
|
||||
|
||||
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
|
||||
|
||||
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
|
||||
const ROW = 44;
|
||||
const VH = 800;
|
||||
const TOTAL = 50938;
|
||||
|
||||
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
|
||||
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||
const rendered = w.end - w.start;
|
||||
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
|
||||
// ~18 rows fit in 800px, plus buffer above and below.
|
||||
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
|
||||
});
|
||||
|
||||
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
|
||||
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||
assert.equal(w.start, 0);
|
||||
assert.equal(w.padRowsTop, 0);
|
||||
assert.equal(w.padRowsBottom, TOTAL - w.end);
|
||||
});
|
||||
|
||||
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
|
||||
const scrolled = 10000 * ROW; // row 10,000 at the fold
|
||||
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
|
||||
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
|
||||
assert.ok(w.end > w.start);
|
||||
// The invariant that keeps the scrollbar honest: padding rows + rendered
|
||||
// rows must account for every song, or the list changes height as you scroll.
|
||||
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||
});
|
||||
|
||||
test('at the very bottom: no bottom padding, end lands on the last song', () => {
|
||||
const rows = TOTAL;
|
||||
const scrolled = rows * ROW - VH; // scrolled to the end
|
||||
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
|
||||
assert.equal(w.end, TOTAL);
|
||||
assert.equal(w.padRowsBottom, 0);
|
||||
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
|
||||
});
|
||||
|
||||
test('grid view: perRow songs collapse into one row', () => {
|
||||
const perRow = 6;
|
||||
const rows = Math.ceil(TOTAL / perRow);
|
||||
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
|
||||
assert.equal(w.start, 0);
|
||||
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
|
||||
assert.ok(w.end <= TOTAL);
|
||||
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
|
||||
});
|
||||
|
||||
test('scrolled far past the list: keeps one row, never a negative window', () => {
|
||||
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
|
||||
assert.ok(w.end > w.start, 'window must never invert');
|
||||
assert.ok(w.start >= 0 && w.end <= TOTAL);
|
||||
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||
});
|
||||
|
||||
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
|
||||
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
|
||||
assert.equal(w.start, 0);
|
||||
assert.ok(w.end > 0);
|
||||
});
|
||||
|
||||
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
|
||||
// Measured height of 0 (e.g. list still display:none) must not divide by zero
|
||||
// and must not silently render an empty list.
|
||||
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
|
||||
assert.equal(w.start, 0);
|
||||
assert.equal(w.end, TOTAL);
|
||||
assert.equal(w.padRowsTop, 0);
|
||||
assert.equal(w.padRowsBottom, 0);
|
||||
});
|
||||
|
||||
test('small lists are below the virtualization threshold', () => {
|
||||
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
|
||||
});
|
||||
|
||||
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
|
||||
// perRow and rows were originally captured once at fill time. paint() also runs
|
||||
// on resize, so a narrower/wider window changed the column count while the
|
||||
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
|
||||
// the padding. These pin that the geometry is a function of perRow, so a stale
|
||||
// perRow cannot silently survive.
|
||||
|
||||
test('resizing the grid to fewer columns re-windows against the new row count', () => {
|
||||
const total = 10000;
|
||||
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
|
||||
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
|
||||
|
||||
// Same viewport, half the columns -> about half as many songs on screen.
|
||||
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
|
||||
// ...and the total must still add up, or the scrollbar lies after a resize.
|
||||
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
|
||||
const rows = Math.ceil(total / perRow);
|
||||
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
|
||||
`rows must account for every song at perRow=${perRow}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale perRow would break the total-height invariant (the bug)', () => {
|
||||
const total = 10000;
|
||||
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
|
||||
// the row count no longer matches the geometry, and the padding is wrong.
|
||||
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
|
||||
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
|
||||
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
|
||||
assert.notEqual(accounted, actualRows,
|
||||
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
|
||||
'metrics() recomputes both together on every paint so this cannot happen in practice');
|
||||
});
|
||||
|
||||
test('scrolled grid window always starts on a row boundary', () => {
|
||||
const total = 10000, perRow = 4;
|
||||
const rows = Math.ceil(total / perRow);
|
||||
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
|
||||
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
|
||||
});
|
||||
@@ -155,7 +155,7 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
|
||||
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
|
||||
- `getNoteState(note, chartTime)` — feedBack#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'` → `mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'` → `mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
|
||||
|
||||
`tuning` and `capo` aren't consumed by this plugin.
|
||||
`tuning` and `capo` feed only the nut's open-string pitch labels. They prefer the bundle's effective values; `songInfo` remains the original metadata fallback. Note placement never reads them.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.5",
|
||||
"version": "3.54.0",
|
||||
"type": "visualization",
|
||||
"scriptType": "module",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
"styles": "assets/plugin.css",
|
||||
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
|
||||
"settings": {
|
||||
"html": "settings.html",
|
||||
"category": "graphics",
|
||||
"server_files": [
|
||||
"plugin_uploads/highway_3d/current.mp4",
|
||||
"plugin_uploads/highway_3d/current.webm"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py",
|
||||
"tour": "tour.json"
|
||||
}
|
||||
|
||||
+1181
-10979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,850 @@
|
||||
// h3d-carve-12: T-section (arpeggio inference) extracted from screen.js.
|
||||
// VERBATIM-MOVE: all function bodies are byte-for-byte identical to their
|
||||
// screen.js originals except for the single DI rewire noted below.
|
||||
// No logic changes, no new guards, no structural additions.
|
||||
//
|
||||
// Beyond-subst changes:
|
||||
// 1. arpeggioLaneDividerXYScaleMatchFrameRim: `nStr` → `getNStr()`
|
||||
// (the explicit argument `sY(nStr - 1)` — sY itself is a plain shorthand
|
||||
// that already captures live state internally, but the argument needs getNStr())
|
||||
// 2. _resetStringDependentCaches: STAYS in screen.js; this module exports
|
||||
// `resetChordShapeCache()` instead, which screen.js calls to reset
|
||||
// _chordShapeCache (the only cache that moved here).
|
||||
//
|
||||
// lowerBoundT imported directly from ./geometry.js — not in DI surface.
|
||||
// Total DI params: 19 (18 plain const shorthand + 1 live getter).
|
||||
// Missed in contract survey (corrected before GO): NEXT_ON_STRING_T_EPS (line 248).
|
||||
|
||||
import { lowerBoundT } from './geometry.js';
|
||||
|
||||
export function createArp({
|
||||
// ── plain const shorthand (fn refs or number consts, never reassigned) ──
|
||||
validString, // IIFE fn decl ~line 3736
|
||||
filterValidNotes, // IIFE fn decl ~line 3767 — used by arpeggioLaneDividerFrameAccentMul
|
||||
sY, // factory-scope fn: s => S_BASE + (...) * S_GAP (captures live vars)
|
||||
K, // module-level const line 124
|
||||
S_GAP, // module-level const line 203
|
||||
BEHIND, // module-level const line 206
|
||||
CHORD_FRAME_RIM_MIN, // line 610
|
||||
CHORD_FRAME_RIM_FRAC_H, // line 611
|
||||
ARP_FRAME_ONSET_PAD_S, // line 620
|
||||
ARP_FRAME_ONSET_CLUSTER_S, // line 621
|
||||
ARP_INFER_MIN_HAND_SHAPE_SPAN_S, // line 628
|
||||
ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S, // line 634
|
||||
ARP_INFER_MULTI_STRUM_HIT_SLACK, // line 640
|
||||
ARP_INFER_MULTI_STRUM_WIN_MIN_S, // line 642
|
||||
ARP_INFER_MIN_HITS_VS_SHAPE_CAP, // line 653
|
||||
ARP_HWY_RAIL_END_TAIL_S, // line 263
|
||||
ARP_HWY_RAIL_START_LEAD_S, // line 265
|
||||
NEXT_ON_STRING_T_EPS, // line 248 — used in chordShapeCoveredByStandaloneNotes
|
||||
// ── live getter (let var, reassigned per arrangement/frame) ──
|
||||
getNStr, // () => nStr — used in arpeggioLaneDividerXYScaleMatchFrameRim
|
||||
}) {
|
||||
|
||||
// ── Pre-arp utilities ─────────────────────────────────────────────
|
||||
function truthyChartFlag(v) {
|
||||
if (v === true || v === 1) return true;
|
||||
if (v === '1') return true;
|
||||
return typeof v === 'string' && v.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/** RS / sloppak `hd` (highDensity); tolerate occasional string forms. */
|
||||
function chordWireHighDensity(ch) {
|
||||
return truthyChartFlag(ch && ch.hd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per spec, `displayName` is the UI label for a chord template
|
||||
* (defaulting to `name` when the chart didn't set it). Always go
|
||||
* through this helper so name vs. displayName drift can't surface
|
||||
* the wrong label or break displayName-based dedupe heuristics.
|
||||
*/
|
||||
function chordTemplateLabel(tmpl) {
|
||||
if (!tmpl) return '';
|
||||
const d = tmpl.displayName;
|
||||
if (typeof d === 'string' && d.length > 0) return d;
|
||||
const n = tmpl.name;
|
||||
return typeof n === 'string' ? n : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Arpeggio styling is driven by authored metadata, not by post-hoc
|
||||
* note-stream inference. Prefer explicit hand-shape flags and fall back
|
||||
* to template markers when present.
|
||||
*/
|
||||
function chordTemplateMarkedArpeggio(cid, chordTemplates) {
|
||||
if (cid == null || !chordTemplates) return false;
|
||||
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
|
||||
if (!tmpl) return false;
|
||||
if (truthyChartFlag(tmpl.arp) || truthyChartFlag(tmpl.arpeggio)) return true;
|
||||
const displayName = typeof tmpl.displayName === 'string' ? tmpl.displayName.toLowerCase() : '';
|
||||
if (displayName.includes('-arp')) return true;
|
||||
const name = typeof tmpl.name === 'string' ? tmpl.name.toLowerCase() : '';
|
||||
return name.endsWith('(arp)') || name.includes(' arpeggio');
|
||||
}
|
||||
|
||||
function handShapeMarkedArpeggio(hs, chordTemplates) {
|
||||
if (!hs) return false;
|
||||
if (truthyChartFlag(hs.arp) || truthyChartFlag(hs.arpeggio)) return true;
|
||||
return chordTemplateMarkedArpeggio(hsChordIdNorm(hs), chordTemplates);
|
||||
}
|
||||
|
||||
// ── Hint cache ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Matching hand-shape metadata for a chord onset. ``explicit`` follows
|
||||
* authored arpeggio markers only; note inference is handled separately
|
||||
* by the callers that still need it for non-visual behavior.
|
||||
*
|
||||
* Cached per chord: result depends only on (ch, hss, chordTemplates),
|
||||
* all chart-static for the lifetime of an arrangement. The cache is
|
||||
* swapped on (hss, templates) ref change so an arrangement switch
|
||||
* cannot resurrect stale entries. Empty-input case bypasses the cache
|
||||
* — it returns a fresh sentinel anyway and isn't hot enough to share.
|
||||
*/
|
||||
const _HINT_NONE = Object.freeze({ explicit: false, covered: false, hs: null });
|
||||
let _hintCache = new WeakMap();
|
||||
let _hintCacheHsRef = null;
|
||||
let _hintCacheTplRef = null;
|
||||
function chordHandShapeArpeggioHint(ch, hss, chordTemplates) {
|
||||
if (!hss || hss.length === 0) return _HINT_NONE;
|
||||
if (_hintCacheHsRef !== hss || _hintCacheTplRef !== chordTemplates) {
|
||||
_hintCache = new WeakMap();
|
||||
_hintCacheHsRef = hss;
|
||||
_hintCacheTplRef = chordTemplates;
|
||||
}
|
||||
const cached = _hintCache.get(ch);
|
||||
if (cached !== undefined) return cached;
|
||||
const t = ch.t;
|
||||
const cid = ch.id;
|
||||
let result = _HINT_NONE;
|
||||
for (let i = 0; i < hss.length; i++) {
|
||||
const hs = hss[i];
|
||||
const tLo = hsStart(hs);
|
||||
const tHi = hsEnd(hs);
|
||||
if (Number.isNaN(tLo) || Number.isNaN(tHi)) continue;
|
||||
if (t + 1e-4 < tLo || t > tHi + 1e-4) continue;
|
||||
const hsCid = hsChordIdNorm(hs);
|
||||
if (hsCid !== cid && Number(hsCid) !== Number(cid)) continue;
|
||||
const explicit = handShapeMarkedArpeggio(hs, chordTemplates);
|
||||
result = { explicit, covered: true, hs };
|
||||
break;
|
||||
}
|
||||
_hintCache.set(ch, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build ``ch.notes`` from ``chordTemplates[cid].frets`` (-1 omitted). */
|
||||
function chordNotesFromTemplate(cid, templates) {
|
||||
if (templates == null || cid == null) return [];
|
||||
const tmpl = templates[cid] ?? templates[Number(cid)];
|
||||
if (!tmpl || !Array.isArray(tmpl.frets)) return [];
|
||||
const out = [];
|
||||
for (let si = 0; si < tmpl.frets.length; si++) {
|
||||
const f = tmpl.frets[si];
|
||||
if (f >= 0 && validString(si)) out.push({ s: si, f, sus: 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart-format fingerpicking passages often have ``<handShape>`` + per-string
|
||||
* ``<note>`` rows but **no** ``<chord>`` events. The 3D chord frame / arp
|
||||
* styling only runs over ``bundle.chords``, so synthesize minimal chord
|
||||
* rows at each hand-shape onset when the chart omits them.
|
||||
*/
|
||||
function mergeHandShapeSynthChords(realChords, handShapes, chordTemplates) {
|
||||
if (!handShapes || handShapes.length === 0) return realChords;
|
||||
const reals = realChords && realChords.length ? realChords : [];
|
||||
const synth = [];
|
||||
const seenSynth = new Set();
|
||||
const tol = 0.028;
|
||||
/**
|
||||
* Suppress a synth chord box when a real chord with the **same trimmed
|
||||
* display name** played within this window — Custom songs commonly authors
|
||||
* several ``<chordTemplate>`` rows that share a display name (with
|
||||
* trailing-whitespace IDs) for fingering variants. The follow-up
|
||||
* hand-shape with no chord row is a fingering hint, not a new strum
|
||||
* (e.g. Jackson 5 "I Want You Back" ~0:27 — Fm7 cid=18 strum followed
|
||||
* by Fm7 cid=19 hand-shape, which earlier produced a stacked second
|
||||
* "Fm7" label and an extra chord frame).
|
||||
*/
|
||||
const SAME_NAME_RUN_S = 0.5;
|
||||
const trimmedTemplateName = (cid) => {
|
||||
if (cid == null || !chordTemplates) return '';
|
||||
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
|
||||
// custom songs commonly authors several <chordTemplate> rows that share
|
||||
// a displayName for fingering variants; the suppression
|
||||
// heuristic in the surrounding code dedupes on the *label*,
|
||||
// not the underlying name, so go through chordTemplateLabel.
|
||||
return chordTemplateLabel(tmpl).trim();
|
||||
};
|
||||
outer: for (let i = 0; i < handShapes.length; i++) {
|
||||
const hs = handShapes[i];
|
||||
const cid = hs.chord_id != null ? hs.chord_id : hs.chordId;
|
||||
const st = hs.start_time != null ? hs.start_time : hs.startTime;
|
||||
if (cid == null || st == null || Number.isNaN(Number(st))) continue;
|
||||
const key = `${cid}|${Number(st).toFixed(3)}`;
|
||||
if (seenSynth.has(key)) continue;
|
||||
seenSynth.add(key);
|
||||
const myName = trimmedTemplateName(cid);
|
||||
for (let j = 0; j < reals.length; j++) {
|
||||
const ch = reals[j];
|
||||
const rid = ch.id;
|
||||
const sameId = rid === cid || Number(rid) === Number(cid);
|
||||
if (sameId && Math.abs(ch.t - st) <= tol) continue outer;
|
||||
// A real strum at the same onset already represents this
|
||||
// chord — never synthesize a phantom on top of it. The
|
||||
// id/name checks alone miss hand-shapes whose template
|
||||
// differs from (or shares no name with) the coincident real
|
||||
// chord — e.g. an edited chart that left a stale hand-shape
|
||||
// template pointing at the pre-edit shape, which then drew a
|
||||
// spurious second power chord beside the real one.
|
||||
if (Math.abs(ch.t - st) <= tol) continue outer;
|
||||
if (!sameId && myName !== '') {
|
||||
const otherName = trimmedTemplateName(rid);
|
||||
if (otherName === myName
|
||||
&& st > ch.t
|
||||
&& st - ch.t <= SAME_NAME_RUN_S) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
const notes = chordNotesFromTemplate(cid, chordTemplates);
|
||||
if (notes.length === 0) continue;
|
||||
const et = hs.end_time != null ? hs.end_time : hs.endTime;
|
||||
synth.push({
|
||||
t: st,
|
||||
id: cid,
|
||||
// `hd` is the chart-format `highDensity` wire field (gallops /
|
||||
// repeated strums), not an arpeggio carrier — arpeggio
|
||||
// intent is read directly from the hand-shape via
|
||||
// chordHandShapeArpeggioHint() downstream. Keep `hd` false
|
||||
// so chordWireHighDensity() / label-suppression behave the
|
||||
// same as for any other non-gallop chord row.
|
||||
hd: false,
|
||||
notes,
|
||||
/** Hand-shape fill-in (no authored chord row) — skip note-stream arp frame. */
|
||||
h3dSynth: true,
|
||||
/** Hand-shape end time — used to draw the shape-sustain border for non-arp cases. */
|
||||
h3dSynthEnd: et != null ? Number(et) : null,
|
||||
});
|
||||
}
|
||||
if (synth.length === 0) return reals;
|
||||
const merged = reals.concat(synth);
|
||||
merged.sort((a, b) => {
|
||||
const dt = a.t - b.t;
|
||||
if (Math.abs(dt) > 1e-6) return dt;
|
||||
const ia = Number(a.id);
|
||||
const ib = Number(b.id);
|
||||
return (ia - ib) || 0;
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Chord-shape cache ─────────────────────────────────────────────
|
||||
/**
|
||||
* Merge chart-format ``chordTemplates[id].frets`` with live ``chordNote`` rows.
|
||||
* Cached via WeakMap on the chord object — chord data never changes after
|
||||
* chart load, so the Map is computed once and reused every frame.
|
||||
* The init-time callers (fillArpeggioGhostInferFlags) pass ephemeral `fakeCh`
|
||||
* objects that are never seen again, so they bypass the cache naturally.
|
||||
*/
|
||||
let _chordShapeCache = new WeakMap();
|
||||
function mergeChordShape(ch, chordNotes, templates) {
|
||||
if (_chordShapeCache.has(ch)) return _chordShapeCache.get(ch);
|
||||
const shape = new Map();
|
||||
const tid = ch && ch.id != null ? ch.id : null;
|
||||
const tmpl = (tid != null && templates)
|
||||
? (templates[tid] ?? templates[Number(tid)])
|
||||
: null;
|
||||
if (tmpl && Array.isArray(tmpl.frets)) {
|
||||
for (let si = 0; si < tmpl.frets.length; si++) {
|
||||
if (!validString(si)) continue;
|
||||
const f = tmpl.frets[si];
|
||||
if (f >= 0) shape.set(si, f);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < chordNotes.length; i++) {
|
||||
const cn = chordNotes[i];
|
||||
if (!validString(cn.s)) continue;
|
||||
if (cn.f < 0) shape.delete(cn.s);
|
||||
else shape.set(cn.s, cn.f);
|
||||
}
|
||||
_chordShapeCache.set(ch, shape);
|
||||
return shape;
|
||||
}
|
||||
|
||||
// h3d-carve-12: screen.js's _resetStringDependentCaches() calls this
|
||||
// instead of directly assigning `_chordShapeCache = new WeakMap()`.
|
||||
// Reset the validString()/nStr-dependent chord caches. Called when nStr
|
||||
// changes so a string count discovered after the first frame (e.g. a
|
||||
// 7-string chart whose stringCount arrives in song_info) doesn't leave
|
||||
// string-6+ notes filtered out of cached chord shapes/signatures.
|
||||
function resetChordShapeCache() {
|
||||
_chordShapeCache = new WeakMap();
|
||||
}
|
||||
|
||||
function hitTimesQualifyArpeggioSpread(hitTimes) {
|
||||
if (hitTimes.length < 2) return false;
|
||||
hitTimes.sort((a, b) => a - b);
|
||||
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
|
||||
if (spread >= 0.03) return true;
|
||||
return hitTimes.length >= 4 && spread >= 0.016;
|
||||
}
|
||||
|
||||
/** RS XML / IPC payloads use snake_case or camelCase field names. */
|
||||
function hsStart(hs) {
|
||||
if (!hs) return NaN;
|
||||
const v = hs.start_time != null ? hs.start_time : hs.startTime;
|
||||
if (v == null) return NaN;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? NaN : n;
|
||||
}
|
||||
function hsEnd(hs) {
|
||||
if (!hs) return NaN;
|
||||
const v = hs.end_time != null ? hs.end_time : hs.endTime;
|
||||
if (v == null) return NaN;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? NaN : n;
|
||||
}
|
||||
function hsChordIdNorm(hs) {
|
||||
if (!hs) return null;
|
||||
const v = hs.chord_id != null ? hs.chord_id : hs.chordId;
|
||||
return v == null ? null : v;
|
||||
}
|
||||
|
||||
/** ``<handShape>`` chart duration in seconds (snake_case or camelCase XML). */
|
||||
function handShapeChartSpanSec(hs) {
|
||||
const a = hsStart(hs), b = hsEnd(hs);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
||||
return Math.max(0, b - a);
|
||||
}
|
||||
|
||||
// ── Infer-pattern cache ───────────────────────────────────────────
|
||||
/**
|
||||
* When ``hd`` is missing/false, detect arpeggio from the **note** stream
|
||||
* using the **full voicing** (template ∪ chord notes). RS often stores the
|
||||
* plucks only in ``notes[]``, not as duplicate chord rows.
|
||||
*
|
||||
* @param {{ tLo: number, tHi: number } | null} [timeWin]
|
||||
* When set (e.g. from ``<handShape>`` span), scan staggered picks
|
||||
* across the whole held-shape window — RS often omits ``arp`` and ``hd``.
|
||||
*/
|
||||
// Cached per chord: result depends on (ch, shape, notesArr) and an
|
||||
// optional timeWin which itself is a function of the chord's matching
|
||||
// <handShape>. Both inputs are chart-static, so the cache invalidates
|
||||
// on (notesArr, hss) ref change — `hss` is threaded in purely as the
|
||||
// invalidation key for the chord-loop caller, which passes a stable
|
||||
// `ch` (reused across frames) and a timeWin that is null until
|
||||
// bundle.handShapes arrives over the WS; without the hss check the
|
||||
// null-timeWin result would stick once handShapes loaded late. shape
|
||||
// comes from mergeChordShape(ch) which is also chart-static, so it
|
||||
// doesn't enter the invalidation key directly. The cache deliberately
|
||||
// stores boolean results; a sentinel distinguishes "not computed"
|
||||
// from "false".
|
||||
let _arpInferCache = new WeakMap();
|
||||
let _arpInferCacheNotesRef = null;
|
||||
let _arpInferCacheHssRef = null;
|
||||
function inferArpeggioFromNotePattern(ch, shape, notesArr, timeWin, hss = null) {
|
||||
if (!notesArr || notesArr.length === 0 || shape.size < 2) return false;
|
||||
if (_arpInferCacheNotesRef !== notesArr || _arpInferCacheHssRef !== hss) {
|
||||
_arpInferCache = new WeakMap();
|
||||
_arpInferCacheNotesRef = notesArr;
|
||||
_arpInferCacheHssRef = hss;
|
||||
}
|
||||
const cached = _arpInferCache.get(ch);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin);
|
||||
_arpInferCache.set(ch, result);
|
||||
return result;
|
||||
}
|
||||
function _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin) {
|
||||
const tHi = timeWin ? timeWin.tHi : ch.t + 2.35;
|
||||
const tLo = timeWin ? timeWin.tLo : ch.t - 0.28;
|
||||
let i2 = lowerBoundT(notesArr, tLo - 0.02);
|
||||
const hitTimes = [];
|
||||
const hitStrings = new Set();
|
||||
for (; i2 < notesArr.length; i2++) {
|
||||
const n = notesArr[i2];
|
||||
if (n.t > tHi) break;
|
||||
if (n.t < tLo) continue;
|
||||
if (!validString(n.s)) continue;
|
||||
const ef = shape.get(n.s);
|
||||
if (ef === undefined || ef !== n.f) continue;
|
||||
hitTimes.push(n.t);
|
||||
hitStrings.add(n.s);
|
||||
}
|
||||
if (!hitTimesQualifyArpeggioSpread(hitTimes)) return false;
|
||||
// A genuine arpeggio SWEEPS across the held shape, so its standalone
|
||||
// notes land on MULTIPLE strings of the shape. When every matching
|
||||
// hit is on a single string, this is a repeated single-string run
|
||||
// (e.g. a palm-muted gallop hammering the chord's root) that happens
|
||||
// to share one string/fret with the chord — NOT an arpeggio. Inferring
|
||||
// one here deferred the chord's gems and made the power chord render as
|
||||
// just that one repeated note (bar 25 of starlight). Require ≥2 strings.
|
||||
if (hitStrings.size < 2) return false;
|
||||
// Strumming/gallop rejection — far more hits than the shape has
|
||||
// strings means the chord's notes are being re-struck repeatedly
|
||||
// (a riff/gallop reusing both power-chord notes), not swept once as
|
||||
// an arpeggio. This guard used to live inside `if (timeWin)`, so it
|
||||
// was skipped for charts with no hand-shapes (timeWin null) — which
|
||||
// let dense two-string gallops over a power chord infer a bogus
|
||||
// arpeggio and defer the chord's gems (bar 88 of starlight: a
|
||||
// (s5:4,s6:2) chord whose root+fifth recur ~16x over 2 s). Apply it
|
||||
// with the actual window span whether or not a hand-shape is present.
|
||||
const winSpan = timeWin ? (timeWin.tHi - timeWin.tLo) : (tHi - tLo);
|
||||
if (winSpan > ARP_INFER_MULTI_STRUM_WIN_MIN_S
|
||||
&& hitTimes.length > shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACK) {
|
||||
return false;
|
||||
}
|
||||
if (timeWin) {
|
||||
if (winSpan < 0.70 && hitTimes.length < 4) {
|
||||
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
|
||||
if (spread < ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S) return false;
|
||||
}
|
||||
// Reject when too few staggered hits for a genuine sweep across
|
||||
// the held shape — see ARP_INFER_MIN_HITS_VS_SHAPE_CAP.
|
||||
const minHits = Math.min(shape.size, ARP_INFER_MIN_HITS_VS_SHAPE_CAP);
|
||||
if (hitTimes.length < minHits) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when standalone note rows already cover every string/fret in the
|
||||
* arpeggio shape, so drawing the chord gems too would duplicate the same
|
||||
* authored passage.
|
||||
*/
|
||||
// Cached per chord: result depends on (ch, shape, notesArr) — chart-
|
||||
// static; the cache invalidates on notesArr ref change. The same
|
||||
// ``ch`` may be queried multiple times per frame from the chord
|
||||
// render loop (deferChordGems / _deferFallback / suppressSynthChord),
|
||||
// so survival across frames is also useful.
|
||||
let _arpCoverCache = new WeakMap();
|
||||
let _arpCoverCacheNotesRef = null;
|
||||
function chordShapeCoveredByStandaloneNotes(ch, shape, notesArr, timeWin) {
|
||||
if (!notesArr || notesArr.length === 0 || !shape || shape.size === 0) return false;
|
||||
if (_arpCoverCacheNotesRef !== notesArr) {
|
||||
_arpCoverCache = new WeakMap();
|
||||
_arpCoverCacheNotesRef = notesArr;
|
||||
}
|
||||
const cached = _arpCoverCache.get(ch);
|
||||
if (cached !== undefined) return cached;
|
||||
const tLo = (timeWin ? timeWin.tLo : ch.t - ARP_FRAME_ONSET_PAD_S) - NEXT_ON_STRING_T_EPS;
|
||||
const tHi = (timeWin ? timeWin.tHi : ch.t + ARP_FRAME_ONSET_CLUSTER_S) + NEXT_ON_STRING_T_EPS;
|
||||
let i2 = lowerBoundT(notesArr, tLo);
|
||||
const matchedStrings = new Set();
|
||||
let result = false;
|
||||
for (; i2 < notesArr.length; i2++) {
|
||||
const n = notesArr[i2];
|
||||
if (n.t > tHi) break;
|
||||
if (!validString(n.s) || matchedStrings.has(n.s)) continue;
|
||||
const ef = shape.get(n.s);
|
||||
if (ef === undefined || ef !== n.f) continue;
|
||||
matchedStrings.add(n.s);
|
||||
if (matchedStrings.size >= shape.size) { result = true; break; }
|
||||
}
|
||||
_arpCoverCache.set(ch, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes in an inferred arpeggio passage are charted in ``notes[]`` with
|
||||
* staggered times; treat them like chord-cluster notes for chart-format-style
|
||||
* board-ghost fret digits (``fromChord`` + template column).
|
||||
*/
|
||||
function arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr) {
|
||||
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0) return null;
|
||||
if (!validString(n.s)) return null;
|
||||
for (let i = 0; i < handShapes.length; i++) {
|
||||
const hs = handShapes[i];
|
||||
const hsLo = hsStart(hs);
|
||||
const hsHi = hsEnd(hs);
|
||||
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
|
||||
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
|
||||
const cid = hsChordIdNorm(hs);
|
||||
if (cid == null) continue;
|
||||
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
|
||||
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
|
||||
const tf = tmpl.frets[n.s];
|
||||
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
|
||||
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
|
||||
if (synthNotes.length === 0) continue;
|
||||
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
|
||||
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
|
||||
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
|
||||
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) continue;
|
||||
if (inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes)) return cid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-frame warmup: ``inferArpeggioFromNotePattern`` depends only on
|
||||
* ``handShape × chart``, not on the candidate note — the old path
|
||||
* recomputed it for every visible note (O(notecount × hs × notescan)).
|
||||
* Fill ``outFlags[i]`` with the boolean once per ``handShapes[i]``.
|
||||
*/
|
||||
function fillArpeggioGhostInferFlags(handShapes, chordTemplates, notesArr, outFlags, outSynthOnsetSet = null) {
|
||||
for (let i = 0; i < handShapes.length; i++) {
|
||||
let infer = false;
|
||||
const hs = handShapes[i];
|
||||
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) {
|
||||
outFlags[i] = false;
|
||||
continue;
|
||||
}
|
||||
const cid = hsChordIdNorm(hs);
|
||||
if (cid != null && notesArr.length > 0) {
|
||||
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
|
||||
if (tmpl && Array.isArray(tmpl.frets)) {
|
||||
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
|
||||
if (synthNotes.length > 0) {
|
||||
const hsLo = hsStart(hs);
|
||||
const hsHi = hsEnd(hs);
|
||||
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
|
||||
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
|
||||
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
|
||||
infer = inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes);
|
||||
// Chord-hold gate: inferArpeggioFromNotePattern can fire true
|
||||
// when open-string notes coincidentally match the template's
|
||||
// open positions but only a SINGLE fretted (f>0) string is
|
||||
// actually played at the handshape onset. Treat that as a
|
||||
// chord hold (not an arpeggio) — clear the arp flag, no
|
||||
// brackets. The original implementation also intended to
|
||||
// record a synthetic sustain extending to hsEnd for the
|
||||
// onset note, but that read-side was never wired up; the
|
||||
// visual decay-before-handshape-end is benign.
|
||||
if (infer) {
|
||||
let _frettedCount = 0;
|
||||
let _onsetNote = null;
|
||||
const _fSeen = new Set();
|
||||
let _ci = lowerBoundT(notesArr, tw.tLo - 0.02);
|
||||
for (; _ci < notesArr.length; _ci++) {
|
||||
const _cn = notesArr[_ci];
|
||||
if (_cn.t > tw.tHi + 0.02) break;
|
||||
if (_cn.t < tw.tLo) continue;
|
||||
if (!validString(_cn.s)) continue;
|
||||
if (shape.get(_cn.s) !== _cn.f) continue;
|
||||
if (_cn.f > 0 && !_fSeen.has(_cn.s)) {
|
||||
_frettedCount++;
|
||||
_fSeen.add(_cn.s);
|
||||
if (_onsetNote === null) _onsetNote = _cn;
|
||||
}
|
||||
}
|
||||
if (_frettedCount <= 1 && _onsetNote !== null) {
|
||||
outFlags[i] = false;
|
||||
continue; // chord hold handled — skip onset-match and outFlags assignment
|
||||
}
|
||||
}
|
||||
// Non-arp template inferred as arpeggio: suppress brackets.
|
||||
// Only explicit arp-marked templates (arp:true / displayName "-arp")
|
||||
// should show [ ] / < > bracket markers.
|
||||
if (infer && outSynthOnsetSet != null
|
||||
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
|
||||
outSynthOnsetSet.add(hsLo);
|
||||
}
|
||||
// Also treat as arp ghost when the hs generated a suppressed
|
||||
// synth chord: any standalone note in the onset window matches
|
||||
// any shape string. Handles patterns where inferArpeggioFromNotePattern
|
||||
// returns false (e.g. repeated arpeggio across a long hs span
|
||||
// triggers the multi-strum rejection), but the player still
|
||||
// needs the "hold this shape" ghost fret numbers on the board.
|
||||
if (!infer) {
|
||||
const _oLo = hsLo - ARP_FRAME_ONSET_PAD_S;
|
||||
const _oHi = hsLo + ARP_FRAME_ONSET_CLUSTER_S;
|
||||
let _oi = lowerBoundT(notesArr, _oLo - 0.02);
|
||||
for (; _oi < notesArr.length; _oi++) {
|
||||
const _on = notesArr[_oi];
|
||||
if (_on.t > _oHi) break;
|
||||
if (_on.t < _oLo) continue;
|
||||
if (shape.get(_on.s) === _on.f) {
|
||||
infer = true;
|
||||
// Only suppress brackets when the handshape is NOT an
|
||||
// explicit arpeggio (arp:true template / displayName "-arp").
|
||||
// Genuine arp handshapes reached via onset-match still need
|
||||
// the [ ] bracket markers — only non-arp synth chords are
|
||||
// "false positives" that should hide the brackets.
|
||||
if (outSynthOnsetSet != null
|
||||
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
|
||||
outSynthOnsetSet.add(hsLo);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
outFlags[i] = infer;
|
||||
}
|
||||
}
|
||||
|
||||
// Chart-static WeakMap cache: note object → chord-id (or null sentinel).
|
||||
// The result depends only on the note's (t, s, f) and the chart's handShapes
|
||||
// + chordTemplates, which never change after load. Keyed by note object so
|
||||
// switching songs/arrangements drops the entries with the old array.
|
||||
const _ARP_CID_NULL = Object.freeze({});
|
||||
const _arpCidCache = new WeakMap();
|
||||
function arpeggioChordIdForNoteWithInferCache(n, handShapes, chordTemplates, notesArr, hsInferFlags) {
|
||||
const cached = _arpCidCache.get(n);
|
||||
if (cached !== undefined) return cached === _ARP_CID_NULL ? null : cached;
|
||||
let result = null;
|
||||
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0 || !hsInferFlags) {
|
||||
result = arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr);
|
||||
} else if (validString(n.s)) {
|
||||
for (let i = 0; i < handShapes.length; i++) {
|
||||
if (!hsInferFlags[i]) continue;
|
||||
const hs = handShapes[i];
|
||||
const hsLo = hsStart(hs);
|
||||
const hsHi = hsEnd(hs);
|
||||
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
|
||||
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
|
||||
const cid = hsChordIdNorm(hs);
|
||||
if (cid == null) continue;
|
||||
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
|
||||
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
|
||||
const tf = tmpl.frets[n.s];
|
||||
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
|
||||
result = cid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_arpCidCache.set(n, result === null ? _ARP_CID_NULL : result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns {start, end} chart-time bounds of the arpeggio handshape that contains
|
||||
* this note, or null when not found. Uses hsInferFlags to skip ruled-out
|
||||
* handshapes; falls back to a full scan when hsInferFlags is null. */
|
||||
// WeakMap cache — arpHsBoundsForNote result is chart-static (note, handShapes,
|
||||
// and hsInferFlags never change after chart load). Each renderer instance has
|
||||
// its own WeakMap, so splitscreen panels don't interfere.
|
||||
// Sentinel: _ARP_BOUNDS_NULL = {} distinguishes "no matching hs" from "uncached".
|
||||
const _ARP_BOUNDS_NULL = Object.freeze({});
|
||||
const _arpBoundsCache = new WeakMap();
|
||||
function arpHsBoundsForNote(n, handShapes, hsInferFlags) {
|
||||
if (!handShapes || handShapes.length === 0) return null;
|
||||
const cached = _arpBoundsCache.get(n);
|
||||
if (cached !== undefined) return cached === _ARP_BOUNDS_NULL ? null : cached;
|
||||
let result = null;
|
||||
for (let i = 0; i < handShapes.length; i++) {
|
||||
if (hsInferFlags && !hsInferFlags[i]) continue;
|
||||
const hs = handShapes[i];
|
||||
const lo = hsStart(hs);
|
||||
const hi = hsEnd(hs);
|
||||
if (Number.isNaN(lo) || Number.isNaN(hi)) continue;
|
||||
if (n.t + 1e-4 < lo || n.t > hi + 1e-4) continue;
|
||||
result = { start: lo, end: hi };
|
||||
break;
|
||||
}
|
||||
_arpBoundsCache.set(n, result === null ? _ARP_BOUNDS_NULL : result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cache the authored arpeggio marker per hand shape. */
|
||||
function handShapeIsArpeggioForLaneRail(hs, chordTemplates) {
|
||||
return handShapeMarkedArpeggio(hs, chordTemplates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart-time window for purple rails: hand-shape span clipped to matching
|
||||
* ``chords[].t`` and template notes in the passage — same times that drive
|
||||
* the 3D arpeggio frame (``ch.t`` + note stream), avoiding rails that start
|
||||
* before the box or end before the last arpeggiated note.
|
||||
*/
|
||||
function effectiveArpRailChartBoundsForHandShape(hs, chords, chordTemplates, notesArr) {
|
||||
let shapeLo = hsStart(hs);
|
||||
const _hsEndOrig = hsEnd(hs);
|
||||
let shapeHi = _hsEndOrig;
|
||||
const cid = hsChordIdNorm(hs);
|
||||
if (Number.isNaN(shapeLo) || Number.isNaN(shapeHi)) {
|
||||
return { shapeLo: 1e9, shapeHi: -1e9 };
|
||||
}
|
||||
if (notesArr && notesArr.length > 0 && chordTemplates && cid != null) {
|
||||
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
|
||||
if (tmpl && Array.isArray(tmpl.frets)) {
|
||||
let tFirst = null;
|
||||
let tLast = null;
|
||||
for (let i = 0; i < notesArr.length; i++) {
|
||||
const n = notesArr[i];
|
||||
if (n.t + 1e-4 < shapeLo - 0.18 || n.t > shapeHi + 0.45) continue;
|
||||
if (!validString(n.s)) continue;
|
||||
const tf = tmpl.frets[n.s];
|
||||
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
|
||||
if (tFirst === null || n.t < tFirst) tFirst = n.t;
|
||||
if (tLast === null || n.t > tLast) tLast = n.t;
|
||||
}
|
||||
if (tFirst != null) shapeLo = Math.max(shapeLo, tFirst);
|
||||
if (tLast != null) shapeHi = Math.max(shapeHi, tLast);
|
||||
}
|
||||
}
|
||||
if (chords && chords.length && cid != null) {
|
||||
let tMinC = null;
|
||||
let tMaxC = null;
|
||||
for (let j = 0; j < chords.length; j++) {
|
||||
const ch = chords[j];
|
||||
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
|
||||
if (ch.t + 1e-4 < shapeLo || ch.t > shapeHi + 0.28) continue;
|
||||
if (tMinC === null || ch.t < tMinC) tMinC = ch.t;
|
||||
if (tMaxC === null || ch.t > tMaxC) tMaxC = ch.t;
|
||||
}
|
||||
if (tMinC != null) shapeLo = Math.max(shapeLo, tMinC);
|
||||
if (tMaxC != null) shapeHi = Math.max(shapeHi, tMaxC);
|
||||
}
|
||||
shapeLo -= ARP_HWY_RAIL_START_LEAD_S;
|
||||
// Only extend past the handshape end when notes/chords genuinely reach
|
||||
// beyond it — otherwise the tail would make the rail visually larger
|
||||
// than the actual handshape duration (e.g. 0.38 s / 1.3 s ≈ 29% extra).
|
||||
if (shapeHi > _hsEndOrig) shapeHi += ARP_HWY_RAIL_END_TAIL_S;
|
||||
return { shapeLo, shapeHi };
|
||||
}
|
||||
|
||||
/** Cache the authored arpeggio marker per hand shape. */
|
||||
function fillLaneRailHandShapeFlags(handShapes, chordTemplates, outFlags) {
|
||||
const nHs = handShapes.length;
|
||||
for (let i = 0; i < nHs; i++) {
|
||||
outFlags[i] = handShapeIsArpeggioForLaneRail(handShapes[i], chordTemplates);
|
||||
}
|
||||
}
|
||||
|
||||
function fillArpeggioRailShapeBoundsCaches(
|
||||
handShapes, chords, chordTemplates, notesArr, laneRailFlags, loOut, hiOut,
|
||||
) {
|
||||
const nHs = handShapes.length;
|
||||
for (let i = 0; i < nHs; i++) {
|
||||
if (!laneRailFlags[i]) continue;
|
||||
const b = effectiveArpRailChartBoundsForHandShape(
|
||||
handShapes[i], chords, chordTemplates, notesArr,
|
||||
);
|
||||
loOut[i] = b.shapeLo;
|
||||
hiOut[i] = b.shapeHi;
|
||||
}
|
||||
}
|
||||
|
||||
/** ``[tChartLo,tChartHi]`` chart times that a lane slice covers (see module ``BEHIND`` / approach ``dt``). */
|
||||
function arpeggioLaneOuterRailChartIntervalOverlaps(
|
||||
tChartLo,
|
||||
tChartHi,
|
||||
handShapes,
|
||||
boundLo,
|
||||
boundHi,
|
||||
laneRailFlags,
|
||||
) {
|
||||
if (!handShapes || handShapes.length === 0) return false;
|
||||
if (!laneRailFlags) return false;
|
||||
if (tChartHi < tChartLo) {
|
||||
const s = tChartLo;
|
||||
tChartLo = tChartHi;
|
||||
tChartHi = s;
|
||||
}
|
||||
for (let i = 0; i < handShapes.length; i++) {
|
||||
if (!laneRailFlags[i]) continue;
|
||||
const shapeLo = boundLo[i];
|
||||
const shapeHi = boundHi[i];
|
||||
if (tChartHi < shapeLo - 1e-4 || tChartLo > shapeHi + 1e-4) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function arpeggioLaneOuterRailLaneSlice(
|
||||
dt0, dt1, nowClock,
|
||||
handShapes, boundLo, boundHi, laneRailFlags,
|
||||
) {
|
||||
const tLo = nowClock + Math.min(dt0, dt1) - BEHIND;
|
||||
const tHi = nowClock + Math.max(dt0, dt1) - BEHIND;
|
||||
return arpeggioLaneOuterRailChartIntervalOverlaps(
|
||||
tLo, tHi, handShapes, boundLo, boundHi, laneRailFlags,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when **chart time** ``chartT`` falls inside an arpeggio hand-shape.
|
||||
* Uses a short end tail only — no ``CHORD_HWY_LINGER_S`` — so purple lane
|
||||
* rails match visible highway slices and do not leak after shapes end.
|
||||
*/
|
||||
function arpeggioLaneOuterRailAtChartTime(
|
||||
chartT, handShapes, boundLo, boundHi, laneRailFlags,
|
||||
) {
|
||||
return arpeggioLaneOuterRailChartIntervalOverlaps(
|
||||
chartT, chartT, handShapes, boundLo, boundHi, laneRailFlags,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same ``chordAccent ? ft *= 1.22`` as the 3D arpeggio chord rim so lane
|
||||
* rails match an accented frame when the active hand shape links to a
|
||||
* chord row that carries ``.ac`` notes.
|
||||
*/
|
||||
function arpeggioLaneDividerFrameAccentMul(nowT, handShapes, chords, boundLo, boundHi, laneRailFlags) {
|
||||
if (!handShapes || handShapes.length === 0 || !chords || chords.length === 0) return 1;
|
||||
if (!laneRailFlags) return 1;
|
||||
for (let i = 0; i < handShapes.length; i++) {
|
||||
if (!laneRailFlags[i]) continue;
|
||||
const shapeLo = boundLo[i];
|
||||
const shapeHi = boundHi[i];
|
||||
if (nowT + 1e-4 < shapeLo || nowT > shapeHi + 1e-4) continue;
|
||||
|
||||
const cid = hsChordIdNorm(handShapes[i]);
|
||||
if (cid == null) return 1;
|
||||
for (let j = 0; j < chords.length; j++) {
|
||||
const ch = chords[j];
|
||||
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
|
||||
if (Math.abs(ch.t - hsStart(handShapes[i])) > 0.12) continue;
|
||||
const chordNotes = ch.notes ? filterValidNotes(ch.notes) : [];
|
||||
if (chordNotes.some(cn => cn.ac)) return 1.22;
|
||||
return 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** World-scale XY for purple lane rails = arpeggio ``ftSide`` / ``gLaneDivider`` edge (0.15×K). */
|
||||
function arpeggioLaneDividerXYScaleMatchFrameRim(accentMul = 1) {
|
||||
const yA = sY(0), yB = sY(getNStr() - 1); // DI: nStr → getNStr()
|
||||
const yMinF = Math.min(yA, yB) - S_GAP * 0.8;
|
||||
const yMaxF = Math.max(yA, yB) + S_GAP * 0.8;
|
||||
const fullChordBoxH = yMaxF - yMinF;
|
||||
let ft = Math.max(CHORD_FRAME_RIM_MIN * K, fullChordBoxH * CHORD_FRAME_RIM_FRAC_H);
|
||||
if (accentMul !== 1 && accentMul > 0) ft *= accentMul;
|
||||
const ftSide = ft * 1.55;
|
||||
return ftSide / (0.15 * K);
|
||||
}
|
||||
|
||||
return {
|
||||
// ── exported (called from outside T-section) ──────────────────────
|
||||
chordWireHighDensity, // callers: 9155, 9384, 9791, 9815
|
||||
chordTemplateLabel, // callers: 9790, 10801, 10852
|
||||
chordTemplateMarkedArpeggio, // callers: 9474, 10045
|
||||
chordHandShapeArpeggioHint, // caller: 9112
|
||||
mergeHandShapeSynthChords, // caller: 7965
|
||||
mergeChordShape, // caller: 8982
|
||||
resetChordShapeCache, // caller: _resetStringDependentCaches (screen.js)
|
||||
inferArpeggioFromNotePattern, // caller: 9126
|
||||
chordShapeCoveredByStandaloneNotes, // caller: 9134
|
||||
hsStart, // callers: 8008, 9114, 9152, 9239, 9423, 10040
|
||||
hsEnd, // callers: 8008, 9114, 9240, 9423, 10040
|
||||
handShapeChartSpanSec, // caller: 9125
|
||||
fillArpeggioGhostInferFlags, // caller: 7987
|
||||
arpeggioChordIdForNoteWithInferCache, // caller: 8811
|
||||
arpHsBoundsForNote, // caller: 8819
|
||||
fillLaneRailHandShapeFlags, // caller: 8101
|
||||
fillArpeggioRailShapeBoundsCaches, // caller: 8110
|
||||
arpeggioLaneOuterRailLaneSlice, // caller: 10267
|
||||
arpeggioLaneOuterRailAtChartTime, // caller: 10124
|
||||
arpeggioLaneDividerFrameAccentMul, // callers: 10128, 10366
|
||||
arpeggioLaneDividerXYScaleMatchFrameRim, // callers: 10133, 10371
|
||||
// ── private (T-internal only, not in return) ──────────────────────
|
||||
// truthyChartFlag — only used by T-internal fns
|
||||
// handShapeMarkedArpeggio — only used by T-internal fns
|
||||
// chordNotesFromTemplate — only used by T-internal fns
|
||||
// hitTimesQualifyArpeggioSpread — only called by _inferArpeggioFromNotePatternUncached
|
||||
// _inferArpeggioFromNotePatternUncached — only called by inferArpeggioFromNotePattern
|
||||
// hsChordIdNorm — only used by T-internal fns
|
||||
// arpeggioChordIdForNote — only called by arpeggioChordIdForNoteWithInferCache (line 7378)
|
||||
// handShapeIsArpeggioForLaneRail — only called by fillLaneRailHandShapeFlags (line 7490)
|
||||
// effectiveArpRailChartBoundsForHandShape — only called by fillArpeggioRailShapeBoundsCaches (line 7500)
|
||||
// arpeggioLaneOuterRailChartIntervalOverlaps — only called by Slice/AtChartTime (lines 7540, 7553)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* Butterchurn audio-reactive background control panel — h3d-carve-4.
|
||||
*
|
||||
* Exports _bcIsDesktop() and _bcCreateController().
|
||||
* window.h3dBcApplySettings is assigned at module scope so it is available
|
||||
* before the IIFE runs (R5); settings.html guards the call with ?.(). All
|
||||
* vendor scripts are loaded via DOM <script> injection — never ES import (R2).
|
||||
*/
|
||||
|
||||
/* ── Butterchurn audio-reactive background ──────────────────────────
|
||||
* Mounts a Butterchurn (WebGL MilkDrop) canvas BEHIND the transparent
|
||||
* 3D highway. On desktop it's driven by the guitar/mic input (the song
|
||||
* audio lives in JUCE, not the webview <audio>); in a browser it taps
|
||||
* the song <audio> directly.
|
||||
* ──────────────────────────────────────────────────────────────────── */
|
||||
const BC_VENDOR = '/api/plugins/highway_3d/assets/vendor/';
|
||||
const BC_FRAME = 1024;
|
||||
const BC_WORKLET = '/api/plugins/highway_3d/assets/viz-worklet.js';
|
||||
const _bcMeters = { gtr: 0, song: 0 }; // live levels shown in the panel readout
|
||||
const BC_BTN = 'background:rgba(255,255,255,.09);color:#cfe3ff;border:1px solid rgba(255,255,255,.16);border-radius:5px;padding:3px 8px;cursor:pointer;font:12px system-ui';
|
||||
let _bcLoading = null;
|
||||
function _bcLoadLib() {
|
||||
if (_bcLoading) return _bcLoading;
|
||||
_bcLoading = new Promise((resolve, reject) => {
|
||||
const add = (url, next) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url; s.async = true;
|
||||
s.onload = next; s.onerror = () => reject(new Error('load ' + url));
|
||||
document.head.appendChild(s);
|
||||
};
|
||||
add(BC_VENDOR + 'butterchurn.min.js', () =>
|
||||
add(BC_VENDOR + 'butterchurnPresets.min.js', resolve));
|
||||
});
|
||||
// Don't cache a rejected promise: a transient load failure (network
|
||||
// hiccup, blocked request) must not permanently disable the feature for
|
||||
// the session. Clearing _bcLoading lets the next mount retry the load.
|
||||
_bcLoading.catch(() => { _bcLoading = null; });
|
||||
return _bcLoading;
|
||||
}
|
||||
function _bcResolve() { let b = window.butterchurn; if (b && b.default) b = b.default; return b; }
|
||||
function _bcPresets() { let p = window.butterchurnPresets; if (p && p.default) p = p.default; return p; }
|
||||
export function _bcIsDesktop() {
|
||||
const d = window.feedBackDesktop || window.slopsmithDesktop;
|
||||
return !!(d && d.isDesktop && d.audio && typeof d.audio.getRawAudioFrame === 'function');
|
||||
}
|
||||
// Fast-forward an index to the first entry after time `ct` (used on seek/loop).
|
||||
// Position at the first entry whose time is >= ct (strict <), so an event
|
||||
// landing exactly on the seek/loop target time is still fired by the update
|
||||
// walkers (which consume `<= ct`) instead of being skipped past here.
|
||||
export function _bcFfIdx(arr, ct, key) { if (!arr) return 0; let i = 0; while (i < arr.length && (arr[i][key] || 0) < ct) i++; return i; }
|
||||
// Force-free a canvas's WebGL context so the GPU resources are released
|
||||
// immediately instead of lingering until GC — repeated Butterchurn
|
||||
// mount/unmount cycles otherwise pile up live contexts toward the browser cap.
|
||||
function _bcReleaseCanvasGL(canvas) {
|
||||
if (!canvas || typeof canvas.getContext !== 'function') return;
|
||||
let gl = null;
|
||||
try { gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); } catch (e) { gl = null; }
|
||||
if (!gl || typeof gl.getExtension !== 'function') return;
|
||||
try { const lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext(); } catch (e) {}
|
||||
}
|
||||
|
||||
// Desktop: bridge GUITAR input PCM + SONG output level into a Web Audio node
|
||||
// Butterchurn can tap. Guitar gives spectral texture from your playing; the
|
||||
// song's output meter (getLevels) injects an energy pulse so the visuals also
|
||||
// react to the backing track (JUCE plays it — there's no song PCM to FFT).
|
||||
function _bcGuitarFeed(actx, onReady) {
|
||||
const latest = new Float32Array(BC_FRAME);
|
||||
let polling = true, songLevel = 0, chartLevel = 0;
|
||||
let node = null, sp = null, silent = null;
|
||||
const api = (window.feedBackDesktop || window.slopsmithDesktop).audio;
|
||||
const gainNow = () => (_bcLoadSettings().guitarGain) || 6;
|
||||
|
||||
// Keep the source node processing (silently — JUCE already monitors the
|
||||
// guitar), and hand it to Butterchurn via the onReady callback.
|
||||
function attach(srcNode) {
|
||||
silent = actx.createGain(); silent.gain.value = 0;
|
||||
srcNode.connect(silent); silent.connect(actx.destination);
|
||||
try { if (onReady) onReady(srcNode); } catch (e) {}
|
||||
}
|
||||
// Fallback for contexts without AudioWorklet support.
|
||||
function useScriptProcessor() {
|
||||
let phase = 0, phase2 = 0;
|
||||
const TWO_PI = Math.PI * 2;
|
||||
const oscStep = TWO_PI * (90 / actx.sampleRate);
|
||||
const oscStep2 = TWO_PI * (520 / actx.sampleRate);
|
||||
sp = actx.createScriptProcessor(BC_FRAME, 1, 1);
|
||||
sp.onaudioprocess = (e) => {
|
||||
const out = e.outputBuffer.getChannelData(0);
|
||||
const n = Math.min(out.length, latest.length);
|
||||
const lvl = songLevel, clvl = chartLevel, gg = gainNow();
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const g = (i < n ? latest[i] : 0) * gg;
|
||||
const song = lvl * (0.7 * Math.sin(phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
|
||||
const chart = clvl * (0.5 * Math.sin(phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
|
||||
phase += oscStep; if (phase > TWO_PI) phase -= TWO_PI;
|
||||
phase2 += oscStep2; if (phase2 > TWO_PI) phase2 -= TWO_PI;
|
||||
const v = g + song + chart;
|
||||
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
|
||||
}
|
||||
};
|
||||
attach(sp);
|
||||
console.log('[viz3d] audio feed: ScriptProcessor (fallback)');
|
||||
}
|
||||
|
||||
// Preferred path: AudioWorklet (runs off the main thread).
|
||||
if (actx.audioWorklet && typeof actx.audioWorklet.addModule === 'function' && typeof AudioWorkletNode === 'function') {
|
||||
actx.audioWorklet.addModule(BC_WORKLET).then(() => {
|
||||
if (!polling || sp) return;
|
||||
node = new AudioWorkletNode(actx, 'viz-feed', { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
|
||||
attach(node);
|
||||
console.log('[viz3d] audio feed: AudioWorklet');
|
||||
}).catch((e) => {
|
||||
console.warn('[viz3d] AudioWorklet unavailable, using ScriptProcessor:', e && e.message);
|
||||
if (polling && !sp && !node) useScriptProcessor();
|
||||
});
|
||||
} else {
|
||||
useScriptProcessor();
|
||||
}
|
||||
|
||||
// Guitar PCM poll → waveform + level meter (+ pushed to the worklet).
|
||||
(function pcmLoop() {
|
||||
if (!polling) return;
|
||||
Promise.resolve(api.getRawAudioFrame(BC_FRAME)).then((f) => {
|
||||
if (f && f.length) {
|
||||
if (f.length >= BC_FRAME) latest.set(f.subarray(0, BC_FRAME));
|
||||
else { latest.fill(0); latest.set(f); }
|
||||
let s = 0; for (let i = 0; i < BC_FRAME; i++) s += latest[i] * latest[i];
|
||||
_bcMeters.gtr = Math.sqrt(s / BC_FRAME) * gainNow();
|
||||
if (node) node.port.postMessage({ frame: latest.slice(0), song: songLevel, chart: chartLevel, gain: gainNow() });
|
||||
}
|
||||
}).catch(() => {}).then(() => { if (polling) setTimeout(pcmLoop, 16); });
|
||||
})();
|
||||
// Song output meter poll → music energy pulse.
|
||||
(function levelLoop() {
|
||||
if (!polling) return;
|
||||
Promise.resolve(api.getLevels && api.getLevels()).then((L) => {
|
||||
if (L && typeof L.outputLevel === 'number') {
|
||||
songLevel = Math.min(1, L.outputLevel * ((_bcLoadSettings().songGain) || 1.8));
|
||||
_bcMeters.song = songLevel;
|
||||
if (node) node.port.postMessage({ song: songLevel, chart: chartLevel, gain: gainNow() });
|
||||
}
|
||||
}).catch(() => {}).then(() => { if (polling) setTimeout(levelLoop, 40); });
|
||||
})();
|
||||
|
||||
return {
|
||||
setChart(v) { chartLevel = v; },
|
||||
stop() {
|
||||
polling = false;
|
||||
try { if (sp) { sp.disconnect(); sp.onaudioprocess = null; } } catch (e) {}
|
||||
try { if (node) node.disconnect(); } catch (e) {}
|
||||
try { if (silent) silent.disconnect(); } catch (e) {}
|
||||
}
|
||||
};
|
||||
}
|
||||
// Browser audio is sourced by REUSING the highway's own shared analyser
|
||||
// (the same #audio / stems side-chain tap the fog scenery uses), passed in
|
||||
// as `audioProvider` to _bcCreateController. We deliberately do NOT open a
|
||||
// second createMediaElementSource on #audio here: it can only be called
|
||||
// once per element (a second tap throws InvalidStateError and permanently
|
||||
// disables the other consumer), it would route the song through a fresh,
|
||||
// possibly-suspended context and mute playback, and it would miss the stems
|
||||
// side-chain that sloppaks expose at window.feedBack.stems.getAnalyser().
|
||||
/* ── Controls + readability (localStorage-backed, global config) ───── */
|
||||
const BC_LS = 'viz3d_settings';
|
||||
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8, cyclePool: 'all', hold: false };
|
||||
let _bcSettings = null;
|
||||
export function _bcLoadSettings() {
|
||||
if (_bcSettings) return _bcSettings;
|
||||
let saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem(BC_LS) || '{}'); } catch (e) {}
|
||||
_bcSettings = Object.assign({}, BC_DEFAULTS, saved);
|
||||
return _bcSettings;
|
||||
}
|
||||
function _bcSaveSettings() { try { localStorage.setItem(BC_LS, JSON.stringify(_bcSettings)); } catch (e) {} }
|
||||
const _bcControllers = new Set();
|
||||
function _bcApplyAll() { _bcControllers.forEach((c) => { try { c.applySettings(); } catch (e) {} }); }
|
||||
// Live-apply hook for the plugin's settings.html. The visualizer's on/off +
|
||||
// slider controls now live in the standard settings panel (settings.html),
|
||||
// which persists them into the BC_LS blob and then calls this so a mounted
|
||||
// highway re-reads and applies them immediately. Assigned at module scope
|
||||
// (R5: h3d-carve-4 — 1 beyond-subst vs the IIFE placement; body verbatim)
|
||||
// so it is available before the IIFE runs. settings.html guards with `?.`.
|
||||
window.h3dBcApplySettings = function () {
|
||||
_bcSettings = null; // drop the cache so the next read reloads from localStorage
|
||||
_bcLoadSettings();
|
||||
_bcApplyAll();
|
||||
try { _bcUpdatePanelPreset(); } catch (e) {}
|
||||
};
|
||||
|
||||
// Preset curation: favorites / bans (persisted globally) + the "primary"
|
||||
// controller the panel's preset buttons drive.
|
||||
// Seeded once on first run (reputation-based starter set; user can edit freely).
|
||||
const BC_DEFAULT_FAVORITES = [
|
||||
'Flexi, martin + geiss - dedicated to the sherwin maxawow',
|
||||
'Geiss - Reaction Diffusion 2',
|
||||
'Geiss - Spiral Artifact',
|
||||
'Flexi + Martin - cascading decay swing',
|
||||
'Flexi - mindblob [shiny mix]',
|
||||
'Geiss - Cauldron - painterly 2 (saturation remix)',
|
||||
'Zylot - Paint Spill (Music Reactive Paint Mix)',
|
||||
'Flexi - predator-prey-spirals',
|
||||
'Rovastar + Loadus + Geiss - FractalDrop (Triple Mix)',
|
||||
'Flexi, fishbrain, Geiss + Martin - tokamak witchery',
|
||||
];
|
||||
const BC_DEFAULT_BANS = [
|
||||
'martin - mucus cervix',
|
||||
'Goody - The Wild Vort',
|
||||
'martin - extreme heat',
|
||||
'Unchained - Rewop',
|
||||
'high-altitude basket unraveling - singh grooves nitrogen argon nz+',
|
||||
'$$$ Royal - Mashup (197)',
|
||||
'$$$ Royal - Mashup (431)',
|
||||
'suksma - uninitialized variabowl (hydroponic chronic)',
|
||||
'shifter - dark tides bdrv mix 2',
|
||||
'_Mig_049',
|
||||
];
|
||||
const _bcFavorites = new Set();
|
||||
const _bcBanned = new Set();
|
||||
let _bcListsLoaded = false;
|
||||
function _bcLoadLists() {
|
||||
if (_bcListsLoaded) return; _bcListsLoaded = true;
|
||||
try { (JSON.parse(localStorage.getItem('viz3d_favorites') || '[]') || []).forEach((n) => _bcFavorites.add(n)); } catch (e) {}
|
||||
try { (JSON.parse(localStorage.getItem('viz3d_banned') || '[]') || []).forEach((n) => _bcBanned.add(n)); } catch (e) {}
|
||||
let seeded = false;
|
||||
try { seeded = !!localStorage.getItem('viz3d_seeded'); } catch (e) {}
|
||||
if (!seeded) {
|
||||
BC_DEFAULT_FAVORITES.forEach((n) => _bcFavorites.add(n));
|
||||
BC_DEFAULT_BANS.forEach((n) => _bcBanned.add(n));
|
||||
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
|
||||
_bcSaveLists();
|
||||
}
|
||||
}
|
||||
function _bcSaveLists() {
|
||||
try { localStorage.setItem('viz3d_favorites', JSON.stringify([..._bcFavorites])); } catch (e) {}
|
||||
try { localStorage.setItem('viz3d_banned', JSON.stringify([..._bcBanned])); } catch (e) {}
|
||||
}
|
||||
// Re-add the bundled defaults anytime (merges; a default-fav un-bans, a default-ban un-favs).
|
||||
function _bcRestoreDefaults() {
|
||||
BC_DEFAULT_FAVORITES.forEach((n) => { _bcBanned.delete(n); _bcFavorites.add(n); });
|
||||
BC_DEFAULT_BANS.forEach((n) => { _bcFavorites.delete(n); _bcBanned.add(n); });
|
||||
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
|
||||
_bcSaveLists(); _bcUpdatePanelPreset(); _bcRenderList();
|
||||
}
|
||||
let _bcPrimary = null;
|
||||
let _bcPane = null, _bcListEl = null, _bcFilterEl = null, _bcPaneOpen = false, _bcCollapsed = false;
|
||||
|
||||
function _bcStatusMark(name) {
|
||||
return _bcFavorites.has(name) ? '★ ' : (_bcBanned.has(name) ? '🚫 ' : '');
|
||||
}
|
||||
// Hoisted above the functions that reference it so no-use-before-define
|
||||
// does not flag closure reads inside _bcSetHold, _bcLayout, _bcSetPane,
|
||||
// _bcUpdatePanelPreset. All are closures — _bcPanel is read at call time,
|
||||
// not at module-evaluation time. Original declaration was at line ~316.
|
||||
let _bcPanel = null, _bcPanelKeyBound = false;
|
||||
|
||||
function _bcSetHold(v) {
|
||||
const s = _bcLoadSettings();
|
||||
s.hold = !!v; _bcSaveSettings();
|
||||
const b = _bcPanel && _bcPanel.querySelector('#vz-hold');
|
||||
if (b) b.textContent = s.hold ? '▶ Resume' : '⏸ Hold';
|
||||
}
|
||||
// Drives both panels off the right edge. Order when both open (L→R):
|
||||
// visualizer panel → preset pane → window edge. Pane lives off-screen by
|
||||
// default; opening it shoves the panel LEFT to make room.
|
||||
function _bcLayout() {
|
||||
if (_bcPanel) {
|
||||
let tx = 0;
|
||||
if (_bcCollapsed) tx = 210; // tuck the whole panel off the right edge
|
||||
else if (_bcPaneOpen) tx = -248; // slide panel LEFT to make room for the pane
|
||||
_bcPanel.style.transform = 'translateX(' + tx + 'px) translateY(-50%)';
|
||||
}
|
||||
if (_bcPane) {
|
||||
_bcPane.style.transform = (_bcPaneOpen && !_bcCollapsed) ? 'translateX(0) translateY(-50%)' : 'translateX(calc(100% + 16px)) translateY(-50%)';
|
||||
}
|
||||
}
|
||||
function _bcSetPane(open) {
|
||||
_bcPaneOpen = !!open && !_bcCollapsed;
|
||||
const b = _bcPanel && _bcPanel.querySelector('#vz-listbtn');
|
||||
if (b) b.textContent = _bcPaneOpen ? '>>' : '<<';
|
||||
if (_bcPaneOpen) _bcRenderList();
|
||||
_bcLayout();
|
||||
}
|
||||
function _bcRenderList() {
|
||||
if (!_bcListEl) return;
|
||||
const ctrl = _bcPrimary;
|
||||
const keys = (ctrl && ctrl.keys) ? ctrl.keys : [];
|
||||
const filt = ((_bcFilterEl && _bcFilterEl.value) || '').toLowerCase();
|
||||
const cur = ctrl && ctrl.curName;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const name = keys[i];
|
||||
if (filt && name.toLowerCase().indexOf(filt) === -1) continue;
|
||||
const row = document.createElement('div');
|
||||
row.textContent = _bcStatusMark(name) + name;
|
||||
row.title = name;
|
||||
row.style.cssText = 'padding:3px 7px;border-radius:4px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;' +
|
||||
(name === cur ? 'background:rgba(110,160,255,.28);' : '') + (_bcBanned.has(name) ? 'opacity:.55;' : '');
|
||||
row.addEventListener('click', () => {
|
||||
if (!_bcPrimary) return;
|
||||
_bcPrimary.loadByName(name, 1.0);
|
||||
_bcSetHold(true); // picked from the list → sit on it
|
||||
});
|
||||
frag.appendChild(row);
|
||||
}
|
||||
_bcListEl.innerHTML = '';
|
||||
_bcListEl.appendChild(frag);
|
||||
}
|
||||
function _bcUpdatePanelPreset() {
|
||||
if (!_bcPanel) return;
|
||||
const name = _bcPrimary ? (_bcPrimary.curName || null) : null;
|
||||
const nameEl = _bcPanel.querySelector('#vz-pname');
|
||||
const favBtn = _bcPanel.querySelector('#vz-fav');
|
||||
const banBtn = _bcPanel.querySelector('#vz-ban');
|
||||
const cntEl = _bcPanel.querySelector('#vz-pcount');
|
||||
if (nameEl) { nameEl.textContent = (name ? _bcStatusMark(name) : '') + (name || '—'); nameEl.title = name ? (name + ' — click for full list') : ''; }
|
||||
if (favBtn) favBtn.textContent = (name && _bcFavorites.has(name)) ? '★ Favorited' : '☆ Favorite';
|
||||
if (banBtn) banBtn.textContent = (name && _bcBanned.has(name)) ? '🚫 Banned' : '🚫 Ban';
|
||||
if (cntEl) cntEl.textContent = '★ ' + _bcFavorites.size + ' 🚫 ' + _bcBanned.size;
|
||||
if (_bcPaneOpen) _bcRenderList();
|
||||
}
|
||||
|
||||
// _bcPanel hoisted to before _bcSetHold — see comment there.
|
||||
function _bcEnsurePanel(host) {
|
||||
if (_bcPanel && _bcPanel.isConnected) {
|
||||
// Singleton panel: follow the active highway. If it's still parented
|
||||
// to a different wrap (e.g. another mounted highway instance such as
|
||||
// Virtuoso's embedded one), move it — and the pane — to this wrap so
|
||||
// it appears on whichever highway is currently on-screen.
|
||||
if (host && _bcPanel.parentNode !== host) {
|
||||
host.appendChild(_bcPanel);
|
||||
if (_bcPane) host.appendChild(_bcPane);
|
||||
}
|
||||
return _bcPanel;
|
||||
}
|
||||
const s = _bcLoadSettings();
|
||||
const p = document.createElement('div');
|
||||
p.id = 'viz3d-panel';
|
||||
p.style.cssText = 'position:absolute;top:50%;right:10px;z-index:100000;pointer-events:auto;font:12px/1.45 system-ui,sans-serif;' +
|
||||
'color:#cfe3ff;background:rgba(8,10,20,0.82);padding:9px 11px;border-radius:8px;width:186px;' +
|
||||
'box-shadow:0 2px 12px rgba(0,0,0,0.5);user-select:none;transition:transform 0.28s ease;';
|
||||
p.innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px"><span style="font-weight:600">🌀 Visualizer</span><button id="vz-listbtn" title="Show / hide full preset list" style="' + BC_BTN + ';padding:1px 7px"><<</button></div>' +
|
||||
// On/off + opacity/dim/chart/tint/gain controls now live in the
|
||||
// plugin's Settings panel (settings.html). This in-canvas panel is
|
||||
// only the LIVE preset browser (pick / favorite / ban / cycle).
|
||||
'<div style="opacity:.55;font-size:11px;margin:2px 0 6px">Background & reactivity options are in Settings ▸ 3D Highway.</div>' +
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin:4px 0">' +
|
||||
'<button id="vz-prev" style="' + BC_BTN + '">◀</button>' +
|
||||
'<div id="vz-pname" style="flex:1;text-align:center;font-size:11px;opacity:.9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" title="">—</div>' +
|
||||
'<button id="vz-next" style="' + BC_BTN + '">▶</button>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;margin:4px 0">' +
|
||||
'<button id="vz-fav" style="' + BC_BTN + ';flex:1">♡ Favorite</button>' +
|
||||
'<button id="vz-ban" style="' + BC_BTN + ';flex:1">🚫 Ban</button>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:flex-end;margin:6px 0">' +
|
||||
'<label style="flex:1">Cycle <select id="vz-cyc" style="width:100%;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;padding:3px"><option value="all">All</option><option value="favorites">Favorites</option><option value="bans">Bans</option></select></label>' +
|
||||
'<button id="vz-hold" style="' + BC_BTN + '">⏸ Hold</button>' +
|
||||
'</div>' +
|
||||
'<div style="margin:5px 0 4px;font-size:11px;opacity:.75"><span id="vz-pcount">★ 0 🚫 0</span></div>' +
|
||||
'<div id="vz-meter" style="opacity:.65;margin-top:6px;font:11px/1.3 monospace">gtr — · song —</div>' +
|
||||
'<div style="opacity:.45;margin-top:4px;font-size:11px">` or ‹‹ to hide</div>';
|
||||
(host || document.body).appendChild(p);
|
||||
|
||||
// Slide handle (<< / >>) so the panel can tuck off the right edge and stop
|
||||
// covering the Now / Up-Next labels.
|
||||
const tab = document.createElement('button');
|
||||
tab.textContent = '>>';
|
||||
tab.title = 'Hide / show controls';
|
||||
tab.style.cssText = 'position:absolute;top:6px;left:-23px;width:23px;height:28px;border:none;cursor:pointer;' +
|
||||
'background:rgba(8,10,20,0.82);color:#cfe3ff;border-radius:7px 0 0 7px;font:12px/1 monospace;padding:0;';
|
||||
p.appendChild(tab);
|
||||
tab.addEventListener('click', () => {
|
||||
_bcCollapsed = !_bcCollapsed;
|
||||
if (_bcCollapsed) _bcPaneOpen = false; // collapsing the panel hides the pane too
|
||||
tab.textContent = _bcCollapsed ? '<<' : '>>';
|
||||
const lb = p.querySelector('#vz-listbtn'); if (lb) lb.textContent = _bcPaneOpen ? '>>' : '<<';
|
||||
_bcLayout();
|
||||
});
|
||||
|
||||
// Sliding preset-list pane (sits to the LEFT of the control panel)
|
||||
const pane = document.createElement('div');
|
||||
pane.id = 'viz3d-listpane';
|
||||
pane.style.cssText = 'position:absolute;top:50%;right:10px;z-index:99999;pointer-events:auto;width:236px;max-height:74vh;display:flex;flex-direction:column;' +
|
||||
'background:rgba(8,10,20,0.93);border-radius:8px;box-shadow:0 2px 14px rgba(0,0,0,0.55);color:#cfe3ff;' +
|
||||
'font:12px system-ui,sans-serif;overflow:hidden;transform:translateX(calc(100% + 16px)) translateY(-50%);transition:transform 0.28s ease;';
|
||||
pane.innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 9px 7px 10px;font-weight:600;border-bottom:1px solid rgba(255,255,255,.1)"><span>Presets</span><button id="vz-defaults" title="Restore the bundled default favorites + bans" style="' + BC_BTN + ';font-weight:400">↺ defaults</button></div>' +
|
||||
'<input id="vz-filter" placeholder="filter…" spellcheck="false" style="margin:8px 9px 6px;padding:4px 7px;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;outline:none">' +
|
||||
'<div id="vz-list" style="overflow-y:auto;padding:0 4px 8px"></div>';
|
||||
(host || document.body).appendChild(pane);
|
||||
_bcPane = pane;
|
||||
_bcListEl = pane.querySelector('#vz-list');
|
||||
_bcFilterEl = pane.querySelector('#vz-filter');
|
||||
_bcFilterEl.addEventListener('input', _bcRenderList);
|
||||
pane.querySelector('#vz-defaults').addEventListener('click', _bcRestoreDefaults);
|
||||
|
||||
const q = (id) => p.querySelector(id);
|
||||
_bcPanel = p;
|
||||
|
||||
// Preset curation wiring (favorites / bans / cycle / reset)
|
||||
_bcLoadLists();
|
||||
const cyc = q('#vz-cyc');
|
||||
cyc.value = s.cyclePool || 'all';
|
||||
// Read fresh: settings.html writes can replace _bcSettings, so the `s`
|
||||
// captured at panel creation may be stale by the time this fires.
|
||||
cyc.addEventListener('change', () => { _bcLoadSettings().cyclePool = cyc.value; _bcSaveSettings(); });
|
||||
_bcSetHold(!!s.hold); // sync the Hold button label to the saved state
|
||||
q('#vz-hold').addEventListener('click', () => _bcSetHold(!_bcLoadSettings().hold));
|
||||
q('#vz-listbtn').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
|
||||
q('#vz-pname').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
|
||||
q('#vz-prev').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(-1); });
|
||||
q('#vz-next').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(1); });
|
||||
q('#vz-fav').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.toggleFav(); });
|
||||
q('#vz-ban').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.banCur(); });
|
||||
_bcSetPane(false); // start collapsed; sets the list-button label
|
||||
_bcUpdatePanelPreset();
|
||||
|
||||
// Live level readout — proves the song (not just guitar) is driving things.
|
||||
// Self-stops when the panel is removed (_bcPanel !== p).
|
||||
(function meterLoop() {
|
||||
if (_bcPanel !== p) return;
|
||||
const m = p.querySelector('#vz-meter');
|
||||
if (m) m.textContent = 'gtr ' + _bcMeters.gtr.toFixed(2) + ' · song ' + _bcMeters.song.toFixed(2);
|
||||
setTimeout(meterLoop, 150);
|
||||
})();
|
||||
|
||||
if (!_bcPanelKeyBound) {
|
||||
_bcPanelKeyBound = true;
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key !== '`' || e.metaKey || e.ctrlKey || !_bcPanel) return;
|
||||
const tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
const reveal = _bcPanel.style.display === 'none';
|
||||
_bcPanel.style.display = reveal ? '' : 'none';
|
||||
if (_bcPane) _bcPane.style.display = reveal ? '' : 'none';
|
||||
});
|
||||
}
|
||||
return _bcPanel;
|
||||
}
|
||||
|
||||
// Create a Butterchurn background controller bound to a wrap element.
|
||||
export function _bcCreateController(wrap, sizeProvider, audioProvider) {
|
||||
const ctrl = { viz: null, actx: null, guitar: null, map: null, keys: [], cycle: 0, dead: false, lastW: -1, lastH: -1, canvas: null, backdrop: null, scrim: null, tint: null, wrap: wrap };
|
||||
// Layered DOM in the wrap, all BEHIND the transparent 3D highway:
|
||||
// backdrop(z-4 dark) → bc canvas(z-3) → tint(z-2 instrument color) → scrim(z-1 lane dim)
|
||||
const mkLayer = (cls, css) => { const d = document.createElement('div'); d.className = cls; d.style.cssText = css; wrap.appendChild(d); return d; };
|
||||
const backdrop = mkLayer('viz3d-backdrop', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-4;background:#070710;pointer-events:none;');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'viz3d-bc';
|
||||
canvas.style.cssText = 'position:absolute;top:0;left:0;z-index:-3;pointer-events:none;';
|
||||
wrap.appendChild(canvas);
|
||||
const tint = mkLayer('viz3d-tint', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-2;pointer-events:none;mix-blend-mode:overlay;background:transparent;');
|
||||
const scrim = mkLayer('viz3d-scrim', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1;pointer-events:none;');
|
||||
ctrl.canvas = canvas; ctrl.backdrop = backdrop; ctrl.scrim = scrim; ctrl.tint = tint;
|
||||
|
||||
ctrl.applySettings = function () {
|
||||
const s = _bcLoadSettings();
|
||||
canvas.style.display = s.enabled ? '' : 'none';
|
||||
canvas.style.opacity = String(s.enabled ? s.opacity : 0);
|
||||
if (s.laneDim) {
|
||||
const a = Math.max(0, Math.min(1, s.laneDimStrength)).toFixed(3);
|
||||
scrim.style.display = '';
|
||||
scrim.style.background = 'linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,' + a +
|
||||
') 30%, rgba(0,0,0,' + a + ') 70%, rgba(0,0,0,0) 100%)';
|
||||
} else {
|
||||
scrim.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Preset curation (favorites / bans / cycle mode) ──
|
||||
ctrl.curName = null; ctrl.lastManual = 0;
|
||||
ctrl.allList = () => (ctrl.keys || []).filter((k) => !_bcBanned.has(k));
|
||||
ctrl.pool = () => {
|
||||
const mode = _bcLoadSettings().cyclePool || 'all';
|
||||
if (mode === 'bans') return (ctrl.keys || []).filter((k) => _bcBanned.has(k));
|
||||
if (mode === 'favorites') {
|
||||
const f = (ctrl.keys || []).filter((k) => _bcFavorites.has(k) && !_bcBanned.has(k));
|
||||
if (f.length) return f;
|
||||
}
|
||||
return ctrl.allList();
|
||||
};
|
||||
ctrl.browseArr = () => ctrl.keys || []; // ◀▶ and the list pane walk the full preset list
|
||||
ctrl.loadByName = (name, blend) => {
|
||||
if (!ctrl.viz || !name || !ctrl.map || !ctrl.map[name]) return;
|
||||
try { ctrl.viz.loadPreset(ctrl.map[name], blend || 0); ctrl.curName = name; } catch (e) {}
|
||||
_bcUpdatePanelPreset();
|
||||
};
|
||||
ctrl.autoTick = () => {
|
||||
if (ctrl.dead || _bcLoadSettings().hold) return;
|
||||
if (performance.now() - ctrl.lastManual < 8000) return;
|
||||
const pool = ctrl.pool();
|
||||
if (!pool.length) return;
|
||||
let name = pool[(Math.random() * pool.length) | 0];
|
||||
if (pool.length > 1 && name === ctrl.curName) name = pool[(pool.indexOf(name) + 1) % pool.length];
|
||||
ctrl.loadByName(name, 2.7);
|
||||
};
|
||||
ctrl.step = (dir) => {
|
||||
const list = ctrl.browseArr();
|
||||
if (!list.length) return;
|
||||
let i = list.indexOf(ctrl.curName); if (i < 0) i = (dir > 0 ? -1 : 0);
|
||||
i = (i + dir + list.length) % list.length;
|
||||
ctrl.lastManual = performance.now();
|
||||
ctrl.loadByName(list[i], 1.5);
|
||||
};
|
||||
ctrl.toggleFav = () => {
|
||||
if (!ctrl.curName) return;
|
||||
if (_bcFavorites.has(ctrl.curName)) _bcFavorites.delete(ctrl.curName);
|
||||
else { _bcFavorites.add(ctrl.curName); _bcBanned.delete(ctrl.curName); }
|
||||
_bcSaveLists(); _bcUpdatePanelPreset();
|
||||
};
|
||||
ctrl.banCur = () => {
|
||||
if (!ctrl.curName) return;
|
||||
if (_bcBanned.has(ctrl.curName)) { // un-ban (two-way) — stay on it
|
||||
_bcBanned.delete(ctrl.curName);
|
||||
_bcSaveLists(); _bcUpdatePanelPreset();
|
||||
} else { // ban + advance off it
|
||||
_bcBanned.add(ctrl.curName); _bcFavorites.delete(ctrl.curName);
|
||||
_bcSaveLists(); ctrl.step(1);
|
||||
}
|
||||
};
|
||||
_bcPrimary = ctrl;
|
||||
|
||||
_bcControllers.add(ctrl);
|
||||
_bcEnsurePanel(wrap);
|
||||
ctrl.applySettings();
|
||||
|
||||
_bcLoadLib().then(() => {
|
||||
if (ctrl.dead) return;
|
||||
const bc = _bcResolve();
|
||||
if (!bc || typeof bc.createVisualizer !== 'function') { console.warn('[viz3d] Butterchurn global missing'); return; }
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
const sz = (sizeProvider && sizeProvider()) || { w: 1280, h: 720 };
|
||||
// Browser (Docker/web app): REUSE the highway's existing shared
|
||||
// analyser (the fog scenery's #audio / stems tap) via audioProvider,
|
||||
// and build Butterchurn on that SAME AudioContext so connectAudio()
|
||||
// doesn't fail cross-context. Desktop uses its own context fed by the
|
||||
// guitar/mic input. `ownsActx` tracks whether WE created the context
|
||||
// (so destroy() closes only contexts we own, never the shared one).
|
||||
const fogAudio = _bcIsDesktop() ? null : (audioProvider ? audioProvider() : null);
|
||||
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
|
||||
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
|
||||
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
|
||||
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
|
||||
// render size and report that SAME size to Butterchurn. Its on-screen
|
||||
// pass viewports to the reported size but never sizes the output canvas
|
||||
// itself — leaving the buffer at the 300x150 default blits the whole
|
||||
// visualizer into a corner that CSS then stretches across the highway.
|
||||
// pixelRatio:1 because DPR is now folded into the reported size, so
|
||||
// buffer == viewport == internal texsize (no double-counting).
|
||||
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
|
||||
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
|
||||
canvas.width = _bcW0; canvas.height = _bcH0;
|
||||
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
|
||||
width: _bcW0, height: _bcH0,
|
||||
pixelRatio: 1, textureRatio: 1,
|
||||
});
|
||||
if (_bcIsDesktop()) {
|
||||
try {
|
||||
ctrl.guitar = _bcGuitarFeed(ctrl.actx, (srcNode) => { try { if (ctrl.viz) ctrl.viz.connectAudio(srcNode); } catch (e) {} });
|
||||
console.log('[viz3d] bg: feeding GUITAR input into Butterchurn');
|
||||
} catch (e) { console.warn('[viz3d] guitar feed failed', e); }
|
||||
} else if (fogAudio && fogAudio.analyser) {
|
||||
// The shared AnalyserNode is a passthrough — connecting it onward
|
||||
// to Butterchurn's internal analyser doesn't disturb the fog's reads.
|
||||
try { ctrl.viz.connectAudio(fogAudio.analyser); console.log('[viz3d] browser: Butterchurn tapping shared analyser (' + (fogAudio.source || 'core') + ')'); }
|
||||
catch (e) { console.warn('[viz3d] shared-analyser connect failed', e); }
|
||||
}
|
||||
_bcLoadLists();
|
||||
const presets = _bcPresets();
|
||||
if (presets && typeof presets.getPresets === 'function') { ctrl.map = presets.getPresets(); ctrl.keys = Object.keys(ctrl.map); }
|
||||
const pool0 = ctrl.pool();
|
||||
ctrl.loadByName(pool0.length ? pool0[(Math.random() * pool0.length) | 0] : (ctrl.keys[0] || null), 0.0);
|
||||
ctrl.cycle = setInterval(() => ctrl.autoTick(), 30000);
|
||||
ctrl.connectedAnalyser = (fogAudio && fogAudio.analyser) || null;
|
||||
console.log('[viz3d] Butterchurn ready, presets:', ctrl.keys.length);
|
||||
}).catch((e) => {
|
||||
// Async init failed (lib load, WebGL/context creation, etc.). Clean up
|
||||
// the half-mounted controller so we don't leak an owned AudioContext /
|
||||
// DOM layers, and mark it dead so _bcSyncMode can retry on a later
|
||||
// mount instead of seeing a live-looking but non-functional bcCtrl.
|
||||
console.error('[viz3d] Butterchurn load/init failed', e);
|
||||
try { _bcReleaseCanvasGL(ctrl.canvas); } catch (_) {}
|
||||
try { if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; } } catch (_) {}
|
||||
try { [ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); }); } catch (_) {}
|
||||
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') { try { ctrl.actx.close(); } catch (_) {} }
|
||||
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
});
|
||||
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
|
||||
// device-pixel render size AND report that same size, so buffer ==
|
||||
// on-screen viewport == full fill. Butterchurn never sizes the output
|
||||
// canvas itself; the previous code set only CSS size, leaving the buffer
|
||||
// at the 300x150 default -> the viz showed a stretched lower-left corner
|
||||
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
|
||||
function _bcApplySize(cssW, cssH) {
|
||||
if (!(cssW > 0 && cssH > 0)) return;
|
||||
ctrl.lastW = cssW; ctrl.lastH = cssH;
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
|
||||
if (canvas.width !== bw) canvas.width = bw;
|
||||
if (canvas.height !== bh) canvas.height = bh;
|
||||
const wpx = cssW + 'px', hpx = cssH + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
|
||||
}
|
||||
return {
|
||||
applySettings() { ctrl.applySettings(); },
|
||||
dead() { return ctrl.dead; },
|
||||
ready() { return !!ctrl.viz; },
|
||||
boundAnalyser() { return ctrl.connectedAnalyser || null; },
|
||||
audioCtx() { return ctrl.actx; },
|
||||
// Re-bind audio when the shared analyser changes (e.g. a stems song
|
||||
// swap replaces the analyser). Same context → cheap reconnect; the
|
||||
// caller handles a context change with a full rebuild (cross-context
|
||||
// connectAudio is impossible — the visualizer is bound to one ctx).
|
||||
reconnectAudio(a) {
|
||||
if (!a || !a.analyser || !ctrl.viz) return false;
|
||||
if (a.analyser === ctrl.connectedAnalyser) return true;
|
||||
if (a.ctx && a.ctx !== ctrl.actx) return false; // needs rebuild
|
||||
try { ctrl.viz.connectAudio(a.analyser); ctrl.connectedAnalyser = a.analyser; return true; } catch (e) { return false; }
|
||||
},
|
||||
chart(v) { if (ctrl.guitar && ctrl.guitar.setChart) ctrl.guitar.setChart(v); },
|
||||
tint(hex, alpha) {
|
||||
if (!ctrl.tint) return;
|
||||
if (hex == null) { ctrl.tint.style.background = 'transparent'; return; }
|
||||
const r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
|
||||
ctrl.tint.style.background = 'rgba(' + r + ',' + g + ',' + b + ',' + (alpha || 0).toFixed(3) + ')';
|
||||
},
|
||||
render() {
|
||||
const s = _bcLoadSettings();
|
||||
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
|
||||
const sz = sizeProvider && sizeProvider();
|
||||
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
|
||||
_bcApplySize(sz.w, sz.h);
|
||||
}
|
||||
try { ctrl.viz.render(); } catch (e) {}
|
||||
},
|
||||
resize(w, h) { _bcApplySize(w, h); },
|
||||
destroy() {
|
||||
ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
if (_bcPrimary === ctrl) { _bcPrimary = _bcControllers.values().next().value || null; _bcUpdatePanelPreset(); }
|
||||
if (ctrl.cycle) { clearInterval(ctrl.cycle); ctrl.cycle = 0; }
|
||||
if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; }
|
||||
// Release the Butterchurn WebGL context deterministically (don't
|
||||
// wait for GC) so repeated mounts/toggles can't exhaust the
|
||||
// browser's WebGL context cap (~16). Do it before removing the
|
||||
// canvas from the DOM.
|
||||
_bcReleaseCanvasGL(ctrl.canvas);
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); });
|
||||
ctrl.viz = null; ctrl.connectedAnalyser = null;
|
||||
// Close the AudioContext only if we own it (desktop, or the
|
||||
// browser fallback). The browser path normally reuses the
|
||||
// highway's shared context, which the fog system owns — never
|
||||
// close that. Without this, desktop leaks a new AudioContext per
|
||||
// mount and hits the browser's ~6-context cap after a few toggles.
|
||||
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') {
|
||||
try { ctrl.actx.close(); } catch (e) {}
|
||||
}
|
||||
ctrl.actx = null;
|
||||
if (_bcControllers.size === 0) {
|
||||
if (_bcPanel && _bcPanel.parentNode) _bcPanel.parentNode.removeChild(_bcPanel);
|
||||
if (_bcPane && _bcPane.parentNode) _bcPane.parentNode.removeChild(_bcPane);
|
||||
_bcPanel = null; _bcPane = null; _bcListEl = null; _bcFilterEl = null; _bcPaneOpen = false;
|
||||
} else if (_bcPrimary && _bcPrimary.wrap) {
|
||||
// Splitscreen: a controller other than this one is still
|
||||
// alive. The singleton panel was parented to THIS (now
|
||||
// destroyed) wrap, so re-home it onto the surviving primary's
|
||||
// wrap — otherwise the panel is orphaned on the dead wrap and
|
||||
// the surviving highway is left with no visualizer controls
|
||||
// (_bcEnsurePanel only runs at controller creation). It moves
|
||||
// the existing panel+pane when connected, or rebuilds them on
|
||||
// the survivor if this wrap was already detached.
|
||||
try { _bcEnsurePanel(_bcPrimary.wrap); _bcUpdatePanelPreset(); } catch (e) {}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// h3d-carve-5: player-chrome background control
|
||||
//
|
||||
// Verbatim move of the H-section from screen.js (_pc* symbols, lines 3057-3476
|
||||
// pre-cut). IIFE-scope dependencies injected via factory DI so the module has
|
||||
// no side-effects at import time.
|
||||
//
|
||||
// Beyond-subst (2):
|
||||
// 1. Factory wrapper — closure vars become DI params.
|
||||
// 2. `_venueSceneOverride` → `getVenueSceneOverride()` (live accessor, 1 call
|
||||
// site in _pcSync at what was screen.js:3220).
|
||||
//
|
||||
// screen.js usage:
|
||||
// import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
|
||||
// const { _pcAcquire, _pcRelease } = createBgControl({
|
||||
// BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,
|
||||
// getVenueSceneOverride: () => _venueSceneOverride,
|
||||
// });
|
||||
|
||||
export function createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe, getVenueSceneOverride }) {
|
||||
/* ======================================================================
|
||||
* Player-chrome background control
|
||||
* ======================================================================
|
||||
* A Background picker mounted into the player's Plugins rail popover, so
|
||||
* the background can be switched MID-SONG without leaving for Settings.
|
||||
*
|
||||
* It writes through the SAME global setters settings.html uses
|
||||
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
|
||||
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
|
||||
* is persisted here, and the option list is generated from BG_STYLE_IDS —
|
||||
* add a style there and it shows up in both places automatically.
|
||||
*
|
||||
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
|
||||
* instances but these settings are global — a panel may set a per-panel
|
||||
* override, but this single shared control only ever reads/writes the
|
||||
* global slot (via _bgReadGlobal), so N copies would be N ways to set
|
||||
* one value. init() acquires, destroy() releases,
|
||||
* and the last release unmounts — so the control disappears when the user
|
||||
* switches to a non-3D renderer instead of lingering as a dead knob.
|
||||
*
|
||||
* Everything here is event-driven. No DOM work on a per-frame path.
|
||||
*/
|
||||
// Wording is kept verbatim in sync with settings.html's <option> text so
|
||||
// the same style is not named two different things in two UIs that sit
|
||||
// two clicks apart. An id with no entry here falls back to the raw id.
|
||||
const _PC_LABELS = {
|
||||
off: 'Off', particles: 'Particles (drifting)',
|
||||
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
|
||||
geometric: 'Geometric (rotating shapes)',
|
||||
butterchurn: 'Butterchurn (visualizer)',
|
||||
image: 'Custom image', video: 'Custom video',
|
||||
};
|
||||
// Which settings each background style actually consumes, so a control
|
||||
// that would do nothing is greyed out instead of lying.
|
||||
//
|
||||
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
|
||||
// build() reads settings.intensity, and uses `reactive` if its update()
|
||||
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
|
||||
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
|
||||
// drives its own audio tap and canvas opacity (only the fog-scenery half
|
||||
// falls through to BG_STYLES.off). So neither knob here reaches it - both
|
||||
// are false, and the tooltip points at Butterchurn's own controls.
|
||||
//
|
||||
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
|
||||
// and its row is not updated, the control stays greyed out and lies the
|
||||
// other way. An id missing from this table defaults to both-enabled, which
|
||||
// is the safe direction: a new style is assumed to use its settings.
|
||||
const _PC_USES = {
|
||||
off: { intensity: false, reactive: false, why: 'No background to adjust' },
|
||||
particles: { intensity: true, reactive: true },
|
||||
silhouettes: { intensity: true, reactive: true },
|
||||
lights: { intensity: true, reactive: true },
|
||||
geometric: { intensity: true, reactive: true },
|
||||
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
|
||||
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
|
||||
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
|
||||
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
|
||||
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
|
||||
// active it is the EFFECTIVE style, so both knobs drive nothing.
|
||||
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
|
||||
};
|
||||
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
|
||||
// Non-disabled wrappers around the two greyable controls. A native-disabled
|
||||
// <button>/<input> receives no pointer events, so its `title` tooltip never
|
||||
// shows on hover — the whole "greyed out, says why on hover" affordance
|
||||
// would be dead. The reason lives on these wrappers instead, and the
|
||||
// disabled control gets pointer-events:none so the hover reaches them.
|
||||
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
|
||||
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
|
||||
|
||||
// The player chrome exposes this slot once it has initialised. A host
|
||||
// that does not provide it gets no control (and no error) - the Settings
|
||||
// page remains the way in.
|
||||
function _pcSlot() {
|
||||
try {
|
||||
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
|
||||
// precedent). The playerControlSlot typeof check below already
|
||||
// covers the practical case - only v3 exposes it - but the
|
||||
// documented checklist asks plugins to detect v3 explicitly.
|
||||
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
|
||||
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
|
||||
return typeof fn === 'function' ? fn() : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
// Visual language: these controls sit in the player's Plugin Controls
|
||||
// popover alongside pills from other plugins (Invert, Split, Tuner, the
|
||||
// STEMS group...), so they follow the same look - small rounded pills,
|
||||
// dark fill, brighter on hover, tinted when active.
|
||||
//
|
||||
// Styled INLINE rather than with the Tailwind classes those plugins use
|
||||
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
|
||||
// compiled stylesheet and several of those utilities are not in it, so
|
||||
// using them would mean regenerating assets/plugin.css and bumping the
|
||||
// manifest version. The values below are the resolved tokens from
|
||||
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
|
||||
// #d1d5db), so the result matches without the build step.
|
||||
const _PC_C = {
|
||||
idle: '#181830', // bg-dark-600
|
||||
hover: '#1e1e3a', // bg-dark-500
|
||||
text: '#d1d5db', // text-gray-300
|
||||
textDim: '#6b7280', // text-gray-500 (inert controls)
|
||||
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
|
||||
onText: '#86efac', // text-green-300
|
||||
};
|
||||
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
|
||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||
+ 'transition:background-color .15s,color .15s;';
|
||||
function _pcPill(label, title) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
if (title) b.title = title;
|
||||
b.style.cssText = _PC_PILL;
|
||||
// Hover is a pseudo-class we cannot express inline; these two
|
||||
// listeners reproduce hover:bg-dark-500 for non-active pills only
|
||||
// (an active pill keeps its tint on hover, as the other plugins do).
|
||||
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
|
||||
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
|
||||
return b;
|
||||
}
|
||||
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
|
||||
// when the active background style ignores the setting entirely (see
|
||||
// _pcSync and _PC_USES) - the pill stays visible so the layout
|
||||
// does not jump, but it is inert and says why on hover.
|
||||
function _pcPaint(btn, on, disabled, reason) {
|
||||
btn._on = !!on && !disabled;
|
||||
btn.disabled = !!disabled;
|
||||
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
||||
// A toggle button must expose its state, not just its label.
|
||||
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
|
||||
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
|
||||
// which carries the reason a disabled button's own title can't show.
|
||||
btn.style.pointerEvents = disabled ? 'none' : '';
|
||||
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
|
||||
btn.style.opacity = disabled ? '.45' : '1';
|
||||
btn.title = reason || 'React to the audio';
|
||||
if (disabled) {
|
||||
btn.style.backgroundColor = _PC_C.idle;
|
||||
btn.style.color = _PC_C.textDim;
|
||||
return;
|
||||
}
|
||||
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
|
||||
btn.style.color = on ? _PC_C.onText : _PC_C.text;
|
||||
}
|
||||
function _pcGroupLabel(text) {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = text;
|
||||
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
|
||||
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
|
||||
return el;
|
||||
}
|
||||
// Pull every control back to what is actually stored. Runs on mount and
|
||||
// whenever the settings bus reports one of our keys changed, so editing
|
||||
// from the Settings page updates this control and vice-versa.
|
||||
function _pcSync() {
|
||||
// The active style is the EFFECTIVE one, not the stored one: while the
|
||||
// Venue scene override is on it is what's mounted, and it ignores the
|
||||
// whole Background group - picking a style writes `style` but
|
||||
// _bgMountStyle resolves back to venue, so the dropdown would look
|
||||
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
|
||||
// and the user exits Venue from the visualization picker where they
|
||||
// entered it. An unknown id enables everything rather than disabling
|
||||
// it, so a style added without a _PC_USES row is merely unhelpful.
|
||||
const venue = !!getVenueSceneOverride(); // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride() // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride()
|
||||
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
|
||||
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
|
||||
const why = uses.why || 'This background style ignores this setting';
|
||||
if (_pcReason) _pcReason.textContent = why;
|
||||
// Point a screen reader at the reason, but only while a control is
|
||||
// inert - cleared otherwise so an enabled control is not described by a
|
||||
// stale reason.
|
||||
const _pcDescribe = (el, inert) => {
|
||||
if (!el) return;
|
||||
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
|
||||
else el.removeAttribute('aria-describedby');
|
||||
};
|
||||
_pcDescribe(_pcSel, venue);
|
||||
_pcDescribe(_pcReactive, !uses.reactive);
|
||||
_pcDescribe(_pcIntensity, !uses.intensity);
|
||||
if (_pcSel) {
|
||||
// The custom slots stay unselectable until something is uploaded -
|
||||
// same rule settings.html applies.
|
||||
const img = _pcSel.querySelector('option[value="image"]');
|
||||
const vid = _pcSel.querySelector('option[value="video"]');
|
||||
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
|
||||
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
|
||||
_pcSel.value = _bgReadGlobal('style');
|
||||
// The dropdown still SHOWS the stored style (venue has no option),
|
||||
// but it's inert while Venue owns the scene.
|
||||
_pcSel.disabled = venue;
|
||||
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
|
||||
_pcSel.style.opacity = venue ? '.45' : '1';
|
||||
_pcSel.style.cursor = venue ? 'not-allowed' : '';
|
||||
// Restore the base tooltip when Venue exits — blanking it would
|
||||
// permanently drop the mount-time 'Background style' hint. Matches
|
||||
// how the intensity slider and Reactive pill restore theirs.
|
||||
_pcSel.title = venue ? why : 'Background style';
|
||||
}
|
||||
if (_pcReactive) {
|
||||
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
|
||||
uses.reactive ? 'React to the audio' : why);
|
||||
}
|
||||
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
|
||||
// enabled so the control's own title takes over.
|
||||
if (_pcReactiveWrap) {
|
||||
_pcReactiveWrap.title = uses.reactive ? '' : why;
|
||||
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
|
||||
}
|
||||
if (_pcIntensity) {
|
||||
_pcIntensity.value = String(_bgReadGlobal('intensity'));
|
||||
_pcIntensity.disabled = !uses.intensity;
|
||||
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
|
||||
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
|
||||
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
|
||||
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
|
||||
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
|
||||
}
|
||||
if (_pcIntensityWrap) {
|
||||
_pcIntensityWrap.title = uses.intensity ? '' : why;
|
||||
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
|
||||
}
|
||||
}
|
||||
// Mirror the current values into the Settings panel's controls when it's
|
||||
// in the DOM.
|
||||
//
|
||||
// settings.html hydrates ONCE from localStorage when the panel is injected
|
||||
// and never subscribes to the settings bus, so before this existed there
|
||||
// was only one writer and it could not go stale. Adding the in-player
|
||||
// picker made a second writer, and the panel had no way to hear about it —
|
||||
// change the style mid-song and Settings would still show the old value.
|
||||
//
|
||||
// Assigning .value / .checked programmatically does NOT fire a 'change'
|
||||
// event, so this cannot loop back into the setters.
|
||||
function _pcSyncSettingsPanel() {
|
||||
try {
|
||||
const st = document.getElementById('h3d-bg-style');
|
||||
if (st) st.value = _bgReadGlobal('style');
|
||||
const re = document.getElementById('h3d-bg-reactive');
|
||||
if (re) re.checked = !!_bgReadGlobal('reactive');
|
||||
const inten = _bgReadGlobal('intensity');
|
||||
const ie = document.getElementById('h3d-bg-intensity');
|
||||
if (ie) ie.value = String(inten);
|
||||
// The panel prints the numeric value beside the slider; keep its
|
||||
// formatting identical to settings.html's own hydration.
|
||||
const il = document.getElementById('h3d-bg-intensity-label');
|
||||
if (il) il.textContent = Number(inten).toFixed(2);
|
||||
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
|
||||
}
|
||||
function _pcMount() {
|
||||
// A screen change can swap the popover out from under us, orphaning
|
||||
// the control. Re-resolve only when the cached node is actually gone.
|
||||
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
|
||||
if (_pcEl) return true;
|
||||
const slot = _pcSlot();
|
||||
if (!slot) return false;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'h3d-pc';
|
||||
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
|
||||
// Visually-hidden text carrying the "why greyed out" reason to screen
|
||||
// readers; disabled controls point aria-describedby here. A title alone
|
||||
// is announced unreliably and never on touch. One span suffices - every
|
||||
// greyed control shares the same reason (derived from the single
|
||||
// effective style).
|
||||
_pcReason = document.createElement('span');
|
||||
_pcReason.id = 'h3d-pc-reason';
|
||||
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
|
||||
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
|
||||
box.appendChild(_pcReason);
|
||||
|
||||
box.appendChild(_pcGroupLabel('Background'));
|
||||
// A dropdown, not pills: the style list is 8 entries and growing, and
|
||||
// a pill per style dominated a popover whose other controls are single
|
||||
// toggles. Styled to match the surrounding pills rather than left as a
|
||||
// raw <select>.
|
||||
_pcSel = document.createElement('select');
|
||||
_pcSel.title = 'Background style';
|
||||
_pcSel.setAttribute('aria-label', 'Background style');
|
||||
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
|
||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
|
||||
for (const id of BG_STYLE_IDS) {
|
||||
const o = document.createElement('option');
|
||||
o.value = id;
|
||||
o.textContent = _PC_LABELS[id] || id;
|
||||
_pcSel.appendChild(o);
|
||||
}
|
||||
_pcSel.addEventListener('change', () => {
|
||||
if (_pcSel.disabled) return; // inert under the Venue override
|
||||
try { window.h3dBgSetStyle(_pcSel.value); }
|
||||
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
|
||||
});
|
||||
box.appendChild(_pcSel);
|
||||
const optWrap = document.createElement('div');
|
||||
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
|
||||
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
|
||||
_pcReactive = _pcPill('Reactive', 'React to the audio');
|
||||
_pcReactive.addEventListener('click', () => {
|
||||
if (_pcReactive.disabled) return;
|
||||
try { window.h3dBgSetReactive(!_pcReactive._on); }
|
||||
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
|
||||
});
|
||||
optWrap.appendChild(_pcReactive);
|
||||
box.appendChild(optWrap);
|
||||
|
||||
box.appendChild(_pcGroupLabel('Intensity'));
|
||||
// Wrapper carries the reason on hover when the slider is disabled — a
|
||||
// native-disabled <input> shows no title of its own.
|
||||
_pcIntensityWrap = document.createElement('div');
|
||||
_pcIntensityWrap.style.cssText = 'width:100%;';
|
||||
_pcIntensity = document.createElement('input');
|
||||
_pcIntensity.type = 'range';
|
||||
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
|
||||
_pcIntensity.title = 'Background intensity';
|
||||
_pcIntensity.setAttribute('aria-label', 'Background intensity');
|
||||
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
|
||||
// 'change' (fires on release), NOT 'input'. Every write goes through
|
||||
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
|
||||
// background style down and re-runs build(). On 'input' a single drag
|
||||
// across the range would trigger ~20 full scene rebuilds on the main
|
||||
// thread mid-playback. settings.html's slider makes the same choice:
|
||||
// oninput only repaints its label, onchange calls the setter.
|
||||
_pcIntensity.addEventListener('change', () => {
|
||||
if (_pcIntensity.disabled) return;
|
||||
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
|
||||
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
|
||||
});
|
||||
_pcIntensityWrap.appendChild(_pcIntensity);
|
||||
box.appendChild(_pcIntensityWrap);
|
||||
slot.appendChild(box);
|
||||
_pcEl = box;
|
||||
_pcSync();
|
||||
_pcListener = (key) => {
|
||||
if (key === 'style' || key === 'reactive' || key === 'intensity'
|
||||
|| key === 'customImageDataUrl' || key === 'customVideoName'
|
||||
|| key === 'venueScene') {
|
||||
// 'venueScene' has no dropdown/settings widget of its own, but
|
||||
// toggling Venue changes the EFFECTIVE style, so the greying
|
||||
// must re-evaluate (see _pcSync's effectiveStyle).
|
||||
_pcSync();
|
||||
_pcSyncSettingsPanel();
|
||||
}
|
||||
};
|
||||
_bgSubscribe(_pcListener);
|
||||
return true;
|
||||
}
|
||||
function _pcTeardownDom() {
|
||||
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
|
||||
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
|
||||
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
|
||||
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
|
||||
}
|
||||
function _pcAcquire() {
|
||||
_pcRefs++;
|
||||
_pcBindScreenHook();
|
||||
if (_pcMount()) return;
|
||||
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
|
||||
// the renderer is viable inside the v3 player chrome, and player-chrome.js
|
||||
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
|
||||
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
|
||||
// spinning it out to the ~3s budget for a slot that will never appear.
|
||||
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
|
||||
// The rail popover may not be built yet on a cold load. Retry a few
|
||||
// times, then give up quietly — Settings still works.
|
||||
if (_pcRetryTimer) return;
|
||||
_pcRetry = 0;
|
||||
const tick = () => {
|
||||
_pcRetryTimer = 0;
|
||||
if (_pcRefs <= 0) return; // renderer went away mid-retry
|
||||
// Re-attempt the bus subscription too, not just the mount. On a cold
|
||||
// load the renderer can init before window.feedBack.on exists; the
|
||||
// first _pcBindScreenHook() then no-ops and, without this, the hook
|
||||
// never binds and the control goes permanently deaf to screen
|
||||
// changes. Idempotent via the _pcScreenHook guard.
|
||||
_pcBindScreenHook();
|
||||
if (_pcMount()) return;
|
||||
if (++_pcRetry > 12) return; // ~3s at 250ms
|
||||
_pcRetryTimer = setTimeout(tick, 250);
|
||||
};
|
||||
_pcRetryTimer = setTimeout(tick, 250);
|
||||
}
|
||||
// Re-mount after the player chrome is rebuilt.
|
||||
//
|
||||
// _pcMount's isConnected check can only run when something calls it, and
|
||||
// after the first successful mount nothing did - init() and the retry tick
|
||||
// are the only callers, and the tick stops on success. So a popover that
|
||||
// got swapped out left the control gone until the next song change. This
|
||||
// listener gives that check a real trigger.
|
||||
//
|
||||
// Event-driven and cheap: one _pcMount() call per screen change, and it
|
||||
// early-returns immediately when the cached node is still connected.
|
||||
let _pcScreenHook = null;
|
||||
function _pcBindScreenHook() {
|
||||
if (_pcScreenHook) return;
|
||||
const bus = window.feedBack;
|
||||
if (!bus || typeof bus.on !== 'function') return;
|
||||
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
|
||||
try { bus.on('screen:changed', _pcScreenHook); }
|
||||
catch (e) { _pcScreenHook = null; }
|
||||
}
|
||||
function _pcRelease() {
|
||||
_pcRefs = Math.max(0, _pcRefs - 1);
|
||||
if (_pcRefs > 0) return;
|
||||
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
|
||||
// Drop the screen:changed subscription too, not just the DOM. The
|
||||
// refcount guard inside the hook makes a stale one harmless, but the
|
||||
// listener and its closure would otherwise outlive the control for the
|
||||
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
|
||||
// again, binding another hook to the same bus while the old one stays.
|
||||
// _pcBindScreenHook re-binds on the next acquire.
|
||||
if (_pcScreenHook) {
|
||||
try {
|
||||
const bus = window.feedBack;
|
||||
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
|
||||
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
|
||||
_pcScreenHook = null;
|
||||
}
|
||||
_pcTeardownDom();
|
||||
}
|
||||
|
||||
return { _pcAcquire, _pcRelease };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user