mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 23:14:31 +00:00
Compare commits
104
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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+664
-463
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -682,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,8 +303,9 @@ 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
|
||||
pytest tests/test_plugin_runtime_idempotence.py tests/test_plugins.py tests/test_diagnostics_bundle.py -q
|
||||
```
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) } })),
|
||||
|
||||
+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
|
||||
|
||||
+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,
|
||||
|
||||
+98
-15
@@ -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
|
||||
|
||||
with open(vocals_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{server_url}/align",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
params=params,
|
||||
headers=headers or None,
|
||||
timeout=timeout,
|
||||
# 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}/transcribe",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
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()
|
||||
|
||||
|
||||
+344
-36
@@ -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)
|
||||
@@ -667,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
|
||||
@@ -2405,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:
|
||||
@@ -2566,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""",
|
||||
@@ -2578,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:
|
||||
@@ -2605,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()
|
||||
@@ -2615,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}
|
||||
@@ -2710,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
|
||||
@@ -2810,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 [],
|
||||
@@ -2831,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):
|
||||
@@ -2838,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", [])),
|
||||
@@ -2852,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.
|
||||
@@ -3332,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
|
||||
@@ -3438,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
|
||||
@@ -3450,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
|
||||
@@ -3593,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
|
||||
@@ -3864,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).
|
||||
@@ -3893,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:
|
||||
@@ -3902,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
|
||||
@@ -3941,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' >
|
||||
@@ -4038,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:
|
||||
@@ -4071,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
|
||||
@@ -4169,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",
|
||||
@@ -4190,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 = "",
|
||||
@@ -4204,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,
|
||||
@@ -4212,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
|
||||
@@ -4247,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()
|
||||
@@ -4280,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]),
|
||||
@@ -4301,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
|
||||
@@ -4313,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(
|
||||
@@ -4344,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.
|
||||
@@ -4371,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:
|
||||
@@ -4383,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)
|
||||
|
||||
+69
-19
@@ -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
|
||||
@@ -368,7 +380,9 @@ 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.full_mix:
|
||||
full_mix_url = (
|
||||
@@ -562,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
|
||||
@@ -585,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"
|
||||
_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({
|
||||
"type": "drum_tab",
|
||||
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
|
||||
"name": _dt_name,
|
||||
"kit": kit,
|
||||
"total": len(hits_wire),
|
||||
})
|
||||
await websocket.send_json(_dt_msg)
|
||||
for i in range(0, len(hits_wire), 500):
|
||||
await websocket.send_json({
|
||||
"type": "drum_hits",
|
||||
@@ -733,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"))
|
||||
|
||||
@@ -973,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
|
||||
|
||||
+388
-89
@@ -80,6 +80,20 @@ def find_full_mix(stems: list[dict]) -> dict | None:
|
||||
)
|
||||
|
||||
|
||||
def stem_default_on(raw) -> bool:
|
||||
"""Whether a manifest stem entry plays by default.
|
||||
|
||||
Absent means on. A string is honoured so a hand-written manifest can say
|
||||
`default: off`. Extracted so the WS `ready` payload and the REST song-info
|
||||
payload cannot drift: the stems plugin now preloads from REST and then has
|
||||
to agree with what the WS says a moment later, or it would rebuild the whole
|
||||
graph for nothing.
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
return raw.lower() not in ("off", "false", "0", "no")
|
||||
return bool(raw)
|
||||
|
||||
|
||||
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||
|
||||
@@ -107,6 +121,41 @@ def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
|
||||
|
||||
|
||||
def _resolve_pack_path(source_dir: Path, rel: str, label: str) -> Path | None:
|
||||
"""Resolve a manifest-relative path, contained inside the pack. None if not.
|
||||
|
||||
Every manifest key that names a file routes through here. A crafted manifest
|
||||
must not read outside the sloppak directory via path traversal
|
||||
(e.g. `../../etc`), and a symlink loop or permission error on `.resolve()`
|
||||
must disable that one file rather than abort the whole load — so both
|
||||
failures are caught, and both are warnings rather than raises.
|
||||
|
||||
The two branches log differently on purpose: a `ValueError` means the path
|
||||
resolved *outside* the pack (a crafted or broken manifest), an `OSError`
|
||||
means it could not be resolved at all (symlink loop, permissions). Reading
|
||||
"escapes source_dir" in the logs and reading "resolution failed" lead an
|
||||
operator to very different places, so the distinction is worth two lines.
|
||||
|
||||
Returns the resolved path — **existence is NOT checked here**. Callers
|
||||
differ on that deliberately: a missing optional side-file is silent, while a
|
||||
missing arrangement skips an entry, so each caller keeps its own `.exists()`
|
||||
(or `.is_file()`) test and its own control flow.
|
||||
|
||||
`label` names the manifest key in the log message ("keys", "song_timeline",
|
||||
a drum part's id, …).
|
||||
"""
|
||||
try:
|
||||
p = (source_dir / rel).resolve()
|
||||
p.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e)
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
|
||||
|
||||
@@ -138,16 +187,8 @@ def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
if not isinstance(rel_raw, str) or not rel_raw.strip():
|
||||
return None
|
||||
rel = rel_raw.strip()
|
||||
try:
|
||||
target = (source_dir / rel).resolve()
|
||||
target.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not target.is_file():
|
||||
target = _resolve_pack_path(source_dir, rel, "original_audio")
|
||||
if target is None or not target.is_file():
|
||||
return None
|
||||
log.info(
|
||||
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
|
||||
@@ -684,6 +725,14 @@ class LoadedSloppak:
|
||||
# absent / unreadable / malformed. Streamed over the highway WS as a
|
||||
# `keys` message; consumers (renderers, plugins) read it from there.
|
||||
keys: dict | None = None
|
||||
# Parsed `rigs.json` payload (manifest `rigs:` key, spec §7.9) — the pack's
|
||||
# library of engine-agnostic signal chains: effect chains and, since
|
||||
# feedpak 1.18.0, MIDI-voiced sound sources. Arrangements bind rigs to time
|
||||
# by referencing a rig `id` from `tones.base_rig` / `tones.changes[].rig`
|
||||
# (§6.9), which `lib/tones.py` carries onto the wire. None when absent /
|
||||
# unreadable / malformed. Rig objects are kept verbatim — this loader does
|
||||
# not select realizations or apply the `intent.gm` floor.
|
||||
rigs: dict | None = None
|
||||
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
|
||||
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
|
||||
# None when absent/empty. Streamed over the highway WS (`tempos` /
|
||||
@@ -716,6 +765,227 @@ class LoadedSloppak:
|
||||
# separated stems the moment one drops below 100% — demucs recombination is
|
||||
# lossy, so the mixdown is strictly the better audio when nothing is muted.
|
||||
full_mix: str | None = None
|
||||
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"): one dict
|
||||
# {"id", "name", "drum_tab"} per part, primary FIRST. A part comes from a
|
||||
# `type: drums` arrangement entry carrying a per-arrangement `drum_tab`
|
||||
# file pointer and NO note `file` — entries this loader deliberately never
|
||||
# turns into fretted Arrangements (see the file/notation gate in
|
||||
# load_song; that skip IS the grading invariant). The primary part's
|
||||
# payload is the SAME object as `drum_tab` above (the song-level key is
|
||||
# its back-compat alias). None when the pack has no drums at all; a
|
||||
# single-part list for a legacy pack with only the song-level key.
|
||||
drum_parts: list[dict] | None = None
|
||||
|
||||
|
||||
def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
|
||||
"""Load + schema-validate one drum-tab JSON named by a manifest-relative
|
||||
path. Shared by the song-level `drum_tab:` key and the per-arrangement
|
||||
drum-part pointers (feedpak 1.17.0), so every tab gets the same posture:
|
||||
permissive — a missing file disables that part silently; a traversal,
|
||||
parse, or validation failure disables it with a warning, never aborting
|
||||
the load."""
|
||||
dt_path = _resolve_pack_path(source_dir, rel, label)
|
||||
if dt_path is None or not dt_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = load_json(dt_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse %s %r: %s", label, rel, e)
|
||||
return None
|
||||
ok, reason = drums_mod.validate_drum_tab(raw)
|
||||
if not ok:
|
||||
log.warning("sloppak: %s %r failed validation: %s", label, rel, reason)
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _load_rigs_file(source_dir: Path, rel: str) -> dict | None:
|
||||
"""Load the pack's rig library (manifest `rigs:` key, spec §7.9).
|
||||
|
||||
Returns `{"version": int, "rigs": [...]}` or None. Same permissive posture
|
||||
as every other side-file: missing / unreadable / malformed -> None, never
|
||||
fatal — spec §7.9 is explicit that a rig library a Reader can't use MUST NOT
|
||||
fail the pack.
|
||||
|
||||
Rig objects are kept **verbatim**. Only entries that could never be
|
||||
addressed are dropped — a rig is reachable solely by `id` (from
|
||||
`tones.base_rig` / `changes[].rig`), so a non-dict entry or one without a
|
||||
usable string id is unreferenceable by construction. Everything else,
|
||||
including unknown `role` / `engine` / `kind` values and `ext` namespaces,
|
||||
passes through untouched, because this loader does not interpret rigs:
|
||||
realization selection and the `intent.gm` fallback belong to whatever
|
||||
voices the part.
|
||||
"""
|
||||
try:
|
||||
r_path = (source_dir / rel).resolve()
|
||||
r_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: rigs path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: rigs path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not r_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = load_json(r_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse rigs %r: %s", rel, e)
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
log.warning("sloppak: rigs %r ignored — expected dict, got %s",
|
||||
rel, type(raw).__name__)
|
||||
return None
|
||||
if not isinstance(raw.get("rigs"), list):
|
||||
log.warning("sloppak: rigs %r ignored — 'rigs' must be a list", rel)
|
||||
return None
|
||||
|
||||
clean_rigs: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for rig in raw["rigs"]:
|
||||
if not isinstance(rig, dict):
|
||||
continue
|
||||
rid = rig.get("id")
|
||||
if not isinstance(rid, str) or not rid.strip():
|
||||
continue
|
||||
# Normalize the library side of the lookup the same way the reference
|
||||
# side is normalized in lib/tones.py — otherwise a pack with padded ids
|
||||
# fails to resolve against a stripped `base_rig` / `rig`.
|
||||
rid = rid.strip()
|
||||
# A duplicate id makes `tones.base_rig` ambiguous, which would surface
|
||||
# as the wrong sound rather than an error. First wins, loudly.
|
||||
if rid in seen:
|
||||
log.warning("sloppak: rigs %r has duplicate rig id %r — later one ignored",
|
||||
rel, rid)
|
||||
continue
|
||||
seen.add(rid)
|
||||
clean_rigs.append({**rig, "id": rid})
|
||||
|
||||
# int only — a float version (incl. NaN/Inf, which json.loads accepts)
|
||||
# would raise on int(); default rather than abort an optional side-file.
|
||||
_ver = raw.get("version")
|
||||
return {
|
||||
"version": _ver if isinstance(_ver, int) and not isinstance(_ver, bool) else 1,
|
||||
"rigs": clean_rigs,
|
||||
}
|
||||
|
||||
|
||||
def _entry_tones(entry: dict) -> dict | None:
|
||||
"""A manifest entry's `tones` binding, or None when it doesn't carry one.
|
||||
|
||||
Spec §5.2: a manifest arrangement entry's `tones` overrides the arrangement
|
||||
JSON's `tones` **wholesale** — no field-level merge. This normalizes the
|
||||
"does it carry one" test for both the arrangement path and the drum path.
|
||||
|
||||
An empty dict reads as *absent*, not as "override to silence": it is what a
|
||||
Writer emits by accident, `arrangement_from_wire` already normalizes the
|
||||
in-JSON `{}` to None the same way, and treating it as an override would let
|
||||
a stray empty object silently unbind a part's sound.
|
||||
"""
|
||||
tones = entry.get("tones")
|
||||
return tones if isinstance(tones, dict) and tones else None
|
||||
|
||||
|
||||
def _resolve_drum_parts(
|
||||
source_dir: Path,
|
||||
drum_tab_rel: object,
|
||||
drum_tab_data: dict | None,
|
||||
drum_pointer_entries: list[dict],
|
||||
drum_tones: dict | None = None,
|
||||
) -> tuple[dict | None, list[dict] | None]:
|
||||
"""Resolve drum pointers into a primary-first list with unique ids.
|
||||
|
||||
Also binds each part's sound (feedpak 1.18.0). The precedence mirrors the
|
||||
`drum_tab` alias rule this function already implements: a `type: drums`
|
||||
entry's own `tones` wins for that part, and the song-level `drum_tones` is
|
||||
the fallback for the **primary** part only. A Reader MUST NOT apply both to
|
||||
the same part (spec §5.1/§5.2), which is why the primary picks one or the
|
||||
other here rather than merging them.
|
||||
"""
|
||||
if drum_tab_data is None and not drum_pointer_entries:
|
||||
return drum_tab_data, None
|
||||
|
||||
primary_id = "drums"
|
||||
primary_name = None
|
||||
# The primary's own binding, lifted from its alias pointer entry when it has
|
||||
# one. Stays None if no entry claims the primary — `drum_tones` fills in.
|
||||
primary_tones = None
|
||||
extra_parts: list[dict] = []
|
||||
seen_rels: set[str] = set()
|
||||
# Use the same canonical, traversal-safe identity as zip member lookup so
|
||||
# equivalent spellings ("x.json", "./x.json", or backslashes) identify
|
||||
# one file. Otherwise an alias pointer can reload and duplicate the primary.
|
||||
primary_rel_key = (
|
||||
_zip_member_key(drum_tab_rel.strip())
|
||||
if isinstance(drum_tab_rel, str) and drum_tab_rel.strip() else None
|
||||
)
|
||||
for entry in drum_pointer_entries:
|
||||
rel = str(entry.get("drum_tab") or "").strip()
|
||||
rel_key = _zip_member_key(rel) if rel else None
|
||||
rel_identity = rel_key or rel
|
||||
if not rel or rel_identity in seen_rels:
|
||||
continue
|
||||
seen_rels.add(rel_identity)
|
||||
entry_id = str(entry.get("id") or "").strip()
|
||||
entry_name = str(entry.get("name") or "").strip()
|
||||
if primary_rel_key is not None and rel_key == primary_rel_key:
|
||||
if entry_id:
|
||||
primary_id = entry_id
|
||||
if entry_name:
|
||||
primary_name = entry_name
|
||||
# This entry IS the primary (an alias pointer at the same file), so
|
||||
# its binding is the primary's — and it outranks `drum_tones`.
|
||||
_alias_tones = _entry_tones(entry)
|
||||
if _alias_tones is not None:
|
||||
primary_tones = _alias_tones
|
||||
continue
|
||||
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
|
||||
if tab is None:
|
||||
continue
|
||||
tab_name = tab.get("name")
|
||||
extra_parts.append({
|
||||
"id": entry_id,
|
||||
"name": entry_name
|
||||
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
|
||||
"drum_tab": tab,
|
||||
# Non-primary parts bind through their own entry only; `drum_tones`
|
||||
# is explicitly the primary's fallback, never theirs.
|
||||
"tones": _entry_tones(entry),
|
||||
})
|
||||
|
||||
parts: list[dict] = []
|
||||
used_ids: set[str] = set()
|
||||
if drum_tab_data is not None:
|
||||
if primary_name is None:
|
||||
tab_name = drum_tab_data.get("name")
|
||||
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
|
||||
parts.append({
|
||||
"id": primary_id,
|
||||
"name": primary_name,
|
||||
"drum_tab": drum_tab_data,
|
||||
# Entry `tones` takes precedence; `drum_tones` is the fallback. One
|
||||
# or the other, never both on the same part (spec §5.1).
|
||||
"tones": primary_tones if primary_tones is not None else drum_tones,
|
||||
})
|
||||
used_ids.add(primary_id)
|
||||
|
||||
next_generated_id = 2
|
||||
for part in extra_parts:
|
||||
part_id = part["id"]
|
||||
if not part_id or part_id in used_ids:
|
||||
while f"drums-{next_generated_id}" in used_ids:
|
||||
next_generated_id += 1
|
||||
part_id = f"drums-{next_generated_id}"
|
||||
next_generated_id += 1
|
||||
part["id"] = part_id
|
||||
used_ids.add(part_id)
|
||||
parts.append(part)
|
||||
|
||||
if not parts:
|
||||
return drum_tab_data, None
|
||||
if drum_tab_data is None:
|
||||
drum_tab_data = parts[0]["drum_tab"]
|
||||
return drum_tab_data, parts
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -740,6 +1010,7 @@ def load_song(
|
||||
notation_acc: dict[str, dict] = {}
|
||||
any_notation = False
|
||||
arrangement_ids_acc: list[str | None] = [] # parallel to song.arrangements
|
||||
drum_pointer_entries: list[dict] = [] # feedpak 1.17.0 drum-part pointers
|
||||
for entry in manifest.get("arrangements", []) or []:
|
||||
if not isinstance(entry, dict):
|
||||
log.warning("sloppak: non-dict arrangement entry skipped (%r)", type(entry).__name__)
|
||||
@@ -748,20 +1019,35 @@ def load_song(
|
||||
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
|
||||
notation_raw = entry.get("notation")
|
||||
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
|
||||
if not rel and not has_notation_key:
|
||||
_etype = str(entry.get("type") or "").strip().lower()
|
||||
is_drums = _etype in ("drums", "drum")
|
||||
# A drums-typed entry MUST NEVER become a fretted Arrangement (grading
|
||||
# invariant, spec §5.2/§7.5): route on `type` FIRST, not on file
|
||||
# absence — a malformed drums entry that also carries a note file/
|
||||
# notation would otherwise fall through and grade as garbage.
|
||||
if is_drums or (not rel and not has_notation_key):
|
||||
# A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as
|
||||
# arrangements"): `type: drums` with a per-arrangement `drum_tab`
|
||||
# file. Collect it for the drum-parts load after this loop.
|
||||
if is_drums and isinstance(entry.get("drum_tab"), str):
|
||||
drum_pointer_entries.append(entry)
|
||||
elif is_drums:
|
||||
# Drums-typed but no drum_tab pointer — drop it (any note
|
||||
# file/notation it carries is ignored), never fret it.
|
||||
log.warning(
|
||||
"sloppak: drums-typed arrangement entry %r has no drum_tab pointer — dropped",
|
||||
entry.get("id"),
|
||||
)
|
||||
elif isinstance(entry.get("drum_tab"), str):
|
||||
log.warning(
|
||||
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
|
||||
entry.get("drum_tab"), entry.get("type"),
|
||||
)
|
||||
continue
|
||||
data = None
|
||||
if rel:
|
||||
try:
|
||||
arr_path = (source_dir / rel).resolve()
|
||||
arr_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: arrangement path %r escapes source_dir — skipped", rel)
|
||||
continue
|
||||
except OSError as e:
|
||||
log.warning("sloppak: arrangement path resolution failed (%s) — skipped", e)
|
||||
continue
|
||||
if not arr_path.exists():
|
||||
arr_path = _resolve_pack_path(source_dir, rel, "arrangement")
|
||||
if arr_path is None or not arr_path.exists():
|
||||
continue
|
||||
try:
|
||||
data = load_json(arr_path)
|
||||
@@ -778,6 +1064,11 @@ def load_song(
|
||||
# the arrangement JSON (name, tuning, capo, centOffset).
|
||||
if entry.get("name"):
|
||||
arr.name = str(entry["name"])
|
||||
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335).
|
||||
# Drives arrangement_string_count's bass fallback so a bass authored on
|
||||
# an arrangement whose NAME doesn't say "bass" still reports 4 strings.
|
||||
if entry.get("type"):
|
||||
arr.type = str(entry["type"]).strip().lower()
|
||||
if "tuning" in entry:
|
||||
arr.tuning = list(entry["tuning"])
|
||||
if "capo" in entry:
|
||||
@@ -786,6 +1077,14 @@ def load_song(
|
||||
# _finite_float keeps a malformed manifest NaN/Infinity from
|
||||
# poisoning the song_info JSON (same guard as the wire path).
|
||||
arr.cent_offset = _finite_float(entry["centOffset"])
|
||||
# `tones` overrides WHOLESALE, unlike the field-level overrides above:
|
||||
# the entry's object replaces the arrangement JSON's entirely, with no
|
||||
# per-field merge (spec §5.2). A Writer SHOULD NOT emit both, but when
|
||||
# one does, a half-merged sound — this pack's base with that pack's
|
||||
# changes — would be worse than either source alone.
|
||||
_entry_tone_block = _entry_tones(entry)
|
||||
if _entry_tone_block is not None:
|
||||
arr.tones = _entry_tone_block
|
||||
|
||||
# Beats/sections can live on the arrangement itself in the wire format.
|
||||
# If the manifest-level arrangement JSON carries them, pull them onto
|
||||
@@ -818,15 +1117,7 @@ def load_song(
|
||||
notation_rel = notation_rel.strip()
|
||||
if not notation_rel:
|
||||
continue
|
||||
try:
|
||||
nt_path = (source_dir / notation_rel).resolve()
|
||||
nt_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: notation path %r escapes source_dir — skipped", notation_rel)
|
||||
nt_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: notation path resolution failed (%s) — skipped", e)
|
||||
nt_path = None
|
||||
nt_path = _resolve_pack_path(source_dir, notation_rel, "notation")
|
||||
raw_nt = None
|
||||
if nt_path is not None and nt_path.exists():
|
||||
try:
|
||||
@@ -854,32 +1145,20 @@ def load_song(
|
||||
drum_tab_data: dict | None = None
|
||||
drum_tab_rel = manifest.get("drum_tab")
|
||||
if isinstance(drum_tab_rel, str) and drum_tab_rel:
|
||||
# Constrain to source_dir to prevent a crafted manifest from reading
|
||||
# files outside the sloppak directory via path traversal (e.g. ../../etc).
|
||||
# Wrap both resolve() calls in a broad handler: symlink loops and
|
||||
# permission errors on .resolve() should disable drums, not abort load.
|
||||
try:
|
||||
dt_path = (source_dir / drum_tab_rel).resolve()
|
||||
dt_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: drum_tab path %r escapes source_dir — skipped", drum_tab_rel)
|
||||
dt_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: drum_tab path resolution failed (%s) — skipped", e)
|
||||
dt_path = None
|
||||
if dt_path is not None and dt_path.exists():
|
||||
try:
|
||||
raw = load_json(dt_path)
|
||||
except Exception as e:
|
||||
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
|
||||
raw = None
|
||||
if raw is not None:
|
||||
ok, reason = drums_mod.validate_drum_tab(raw)
|
||||
if ok:
|
||||
drum_tab_data = raw
|
||||
else:
|
||||
log.warning("sloppak: drum_tab %r failed validation: %s",
|
||||
drum_tab_rel, reason)
|
||||
drum_tab_data = _load_drum_tab_file(source_dir, drum_tab_rel, "drum_tab")
|
||||
|
||||
# Keep the dense compatibility logic independently testable and guarantee
|
||||
# ids are unique before the highway exposes them as selectors.
|
||||
# Top-level `drum_tones` (spec §5.1) binds the song-level drum part — the
|
||||
# fallback for packs without `type: drums` arrangements. Same shape as an
|
||||
# arrangement entry's `tones`; `_resolve_drum_parts` owns the precedence.
|
||||
_raw_drum_tones = manifest.get("drum_tones")
|
||||
drum_tones_data = _raw_drum_tones if isinstance(_raw_drum_tones, dict) and _raw_drum_tones else None
|
||||
|
||||
drum_tab_data, drum_parts = _resolve_drum_parts(
|
||||
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
|
||||
drum_tones_data,
|
||||
)
|
||||
|
||||
# Drum-only sloppak: every GP track was percussion, so it ships a
|
||||
# drum_tab but no pitched arrangements. The highway WS rejects an empty
|
||||
@@ -918,15 +1197,7 @@ def load_song(
|
||||
time_sigs_data: list | None = None
|
||||
song_timeline_rel = manifest.get("song_timeline")
|
||||
if isinstance(song_timeline_rel, str) and song_timeline_rel:
|
||||
try:
|
||||
st_path = (source_dir / song_timeline_rel).resolve()
|
||||
st_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: song_timeline path %r escapes source_dir — skipped", song_timeline_rel)
|
||||
st_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: song_timeline path resolution failed (%s) — skipped", e)
|
||||
st_path = None
|
||||
st_path = _resolve_pack_path(source_dir, song_timeline_rel, "song_timeline")
|
||||
if st_path is not None and st_path.exists():
|
||||
try:
|
||||
raw = load_json(st_path)
|
||||
@@ -1016,15 +1287,7 @@ def load_song(
|
||||
# downstream through the WS path.
|
||||
lyrics_rel = manifest.get("lyrics")
|
||||
if isinstance(lyrics_rel, str) and lyrics_rel:
|
||||
try:
|
||||
lyr_path = (source_dir / lyrics_rel).resolve()
|
||||
lyr_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: lyrics path %r escapes source_dir — skipped", lyrics_rel)
|
||||
lyr_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: lyrics path resolution failed (%s) — skipped", e)
|
||||
lyr_path = None
|
||||
lyr_path = _resolve_pack_path(source_dir, lyrics_rel, "lyrics")
|
||||
if lyr_path is not None and lyr_path.exists():
|
||||
try:
|
||||
raw = load_json(lyr_path)
|
||||
@@ -1100,12 +1363,19 @@ def load_song(
|
||||
sfile = str(s.get("file", ""))
|
||||
if not sid or not sfile:
|
||||
continue
|
||||
default_val = s.get("default", True)
|
||||
if isinstance(default_val, str):
|
||||
default_on = default_val.lower() not in ("off", "false", "0", "no")
|
||||
else:
|
||||
default_on = bool(default_val)
|
||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||
entry = {
|
||||
"id": sid,
|
||||
"file": sfile,
|
||||
"default": stem_default_on(s.get("default", True)),
|
||||
}
|
||||
# Optional presentational fields (feedpak 1.16.0, spec §5.3). Omitted —
|
||||
# not None — when absent, so payload builders can pass entries through
|
||||
# without every stem growing null keys.
|
||||
for key in ("name", "description"):
|
||||
val = s.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
entry[key] = val
|
||||
stems.append(entry)
|
||||
|
||||
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
|
||||
# it out so that no consumer of `stems` — the mixer, the library's stem
|
||||
@@ -1122,15 +1392,7 @@ def load_song(
|
||||
keys_data: dict | None = None
|
||||
keys_rel = manifest.get("keys")
|
||||
if isinstance(keys_rel, str) and keys_rel:
|
||||
try:
|
||||
k_path = (source_dir / keys_rel).resolve()
|
||||
k_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: keys path %r escapes source_dir — skipped", keys_rel)
|
||||
k_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: keys path resolution failed (%s) — skipped", e)
|
||||
k_path = None
|
||||
k_path = _resolve_pack_path(source_dir, keys_rel, "keys")
|
||||
if k_path is not None and k_path.exists():
|
||||
try:
|
||||
raw = load_json(k_path)
|
||||
@@ -1175,6 +1437,14 @@ def load_song(
|
||||
"events": clean_events,
|
||||
}
|
||||
|
||||
# Optional rigs.json — the pack's rig library (manifest `rigs:` key,
|
||||
# spec §7.9). Loaded here so the highway WS can hand it to whatever voices
|
||||
# the part; the bindings that reference it ride the arrangement's `tones`.
|
||||
rigs_data: dict | None = None
|
||||
rigs_rel = manifest.get("rigs")
|
||||
if isinstance(rigs_rel, str) and rigs_rel:
|
||||
rigs_data = _load_rigs_file(source_dir, rigs_rel)
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||
@@ -1200,10 +1470,12 @@ def load_song(
|
||||
manifest=manifest,
|
||||
feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None,
|
||||
drum_tab=drum_tab_data,
|
||||
drum_parts=drum_parts,
|
||||
song_timeline=song_timeline_data,
|
||||
tempos=tempos_data,
|
||||
time_signatures=time_sigs_data,
|
||||
keys=keys_data,
|
||||
rigs=rigs_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
full_mix=full_mix_data,
|
||||
@@ -1227,6 +1499,27 @@ def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
|
||||
return [0] * 6
|
||||
|
||||
|
||||
def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None:
|
||||
"""Per-ROLE companion to _tuning_for_meta: the tuning of the arrangement
|
||||
playing `role` ("bass" / "rhythm"), or None when the pack has no such
|
||||
arrangement with a tuning — the index then leaves that perspective's
|
||||
columns empty and the library falls back to the song (guitar-first)
|
||||
tuning, marking the row inferred.
|
||||
|
||||
Exact name first, then a looser containment pass so an alt/bonus chart
|
||||
("Bass 2", "Alt Rhythm") still beats pretending the part is in the lead
|
||||
guitar's tuning."""
|
||||
for match_exact in (True, False):
|
||||
for entry in arrangements_manifest:
|
||||
name = str(entry.get("name", "")).lower()
|
||||
tun = entry.get("tuning")
|
||||
if not (tun and isinstance(tun, list)):
|
||||
continue
|
||||
if name == role if match_exact else role in name:
|
||||
return list(tun)
|
||||
return None
|
||||
|
||||
|
||||
def extract_meta(path: Path) -> dict:
|
||||
"""Fast metadata for the library scanner. Reads only the manifest."""
|
||||
manifest = load_manifest(path)
|
||||
@@ -1249,6 +1542,10 @@ def extract_meta(path: Path) -> dict:
|
||||
|
||||
has_lyrics = bool(manifest.get("lyrics"))
|
||||
tuning_offsets = _tuning_for_meta(arr_list)
|
||||
# Per-role tunings alongside the song-level one, so the library can answer
|
||||
# for whichever arrangement the player actually plays.
|
||||
role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
|
||||
for role in ("bass", "rhythm")}
|
||||
|
||||
stems_list = manifest.get("stems", []) or []
|
||||
valid_stems: list[dict] = []
|
||||
@@ -1287,6 +1584,8 @@ def extract_meta(path: Path) -> dict:
|
||||
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
|
||||
"duration": float(manifest.get("duration", 0) or 0),
|
||||
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||
# None = the pack has no arrangement in that role.
|
||||
**role_tunings,
|
||||
"arrangements": arrangements,
|
||||
"has_lyrics": has_lyrics,
|
||||
"stem_count": stem_count,
|
||||
|
||||
+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.
|
||||
|
||||
+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
|
||||
|
||||
+239
-8
@@ -416,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',
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
.pp-inst-plus { color: #6b7280; }
|
||||
|
||||
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||
subtle grain gradient (no image assets). */
|
||||
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); }
|
||||
@@ -524,7 +525,18 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gold foil preview — honest "coming", never earnable-looking. */
|
||||
/* 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;
|
||||
@@ -534,8 +546,8 @@
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.28rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 2px dashed #c8b273;
|
||||
color: #a8946d;
|
||||
border: 2px solid #d9a253;
|
||||
color: #c89040;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
@@ -600,3 +612,192 @@
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
"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"] },
|
||||
|
||||
+376
-9
@@ -26,11 +26,14 @@ Endpoints (all under /api/plugins/career/):
|
||||
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||
POST /passports/open open a genre passport for an instrument
|
||||
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||
POST /gigs/propose build a playable setlist for a genre gig
|
||||
POST /gigs log a COMPLETED gig (abandoned sets never log)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -43,6 +46,8 @@ from pathlib import Path
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
import sloppak
|
||||
from dlc_paths import _resolve_dlc_path
|
||||
from progression import instrument_for_arrangement
|
||||
|
||||
PLUGIN_ID = "career"
|
||||
@@ -50,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||
DOWNLOAD_CHUNK = 1024 * 256
|
||||
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
|
||||
# arbitrary caller can ask for.
|
||||
MAX_GIG_SONGS = 32
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state = {
|
||||
@@ -96,6 +104,13 @@ def _bundled(venue_id):
|
||||
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
|
||||
|
||||
|
||||
def _pack_published(pack):
|
||||
"""A remote pack is downloadable only once a publish has stamped its real
|
||||
size — the committed manifest carries a 0-byte placeholder (and an all-zero
|
||||
sha) until then, so don't offer a download that can't succeed yet."""
|
||||
return bool(pack and (pack.get("bytes") or 0) > 0)
|
||||
|
||||
|
||||
def _stars():
|
||||
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
|
||||
db = _state["meta_db"]
|
||||
@@ -327,10 +342,11 @@ def _badge_requirement(gkey, instrument="guitar"):
|
||||
def _drill_by_node():
|
||||
doc = _load_json(_drill_file(), {})
|
||||
if not isinstance(doc, dict):
|
||||
return None, {}
|
||||
return None, {}, {}
|
||||
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||
return doc.get("received_at"), by_node
|
||||
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
|
||||
return doc.get("received_at"), by_node, gold
|
||||
|
||||
|
||||
def _merge_drill_nodes(old, new):
|
||||
@@ -364,6 +380,16 @@ def _merge_drill_nodes(old, new):
|
||||
return out
|
||||
|
||||
|
||||
def _merge_gold(old, new):
|
||||
"""Gained-only merge of goldImprov artifacts: a minted style never
|
||||
un-mints via a stale relay; the FIRST artifact per style is kept."""
|
||||
out = dict(old)
|
||||
for style_id, art in (new or {}).items():
|
||||
if isinstance(art, dict) and style_id not in out:
|
||||
out[style_id] = art
|
||||
return out
|
||||
|
||||
|
||||
def _node_cleared(by_node, node_id):
|
||||
"""A drill counts as cleared on real completion evidence: mastered, any
|
||||
depth rung flipped true, or a key cleared (a top-tier clean pass in one
|
||||
@@ -383,8 +409,9 @@ def _passports_view():
|
||||
cfg = _state["passports_content"]
|
||||
graded = set(cfg.get("graded_instruments") or [])
|
||||
st = _career_state()
|
||||
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
||||
played, played_seconds = _played_by_instrument_genre()
|
||||
received_at, by_node = _drill_by_node()
|
||||
received_at, by_node, gold_improv = _drill_by_node()
|
||||
instruments = {}
|
||||
for inst in cfg.get("instruments") or []:
|
||||
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||
@@ -410,7 +437,20 @@ def _passports_view():
|
||||
# false badge denial — the doc's shown-not-judged rule.
|
||||
badge = "shown_not_judged"
|
||||
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||
badge = "earned"
|
||||
# Bronze is earned; GOLD upgrades it when a verified improv
|
||||
# artifact exists for this genre's jam style. Virtuoso mints
|
||||
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
|
||||
# which are mostly NOT family keys — so match in family space:
|
||||
# the same keyword bucketing genres get ('punk' and 'punk
|
||||
# rock' both bucket to 'rock'), with the exact key as a direct
|
||||
# hit. Bronze remains a standalone win; gold never becomes an
|
||||
# obligation.
|
||||
fam = _genre_family(gkey)
|
||||
gold = any(
|
||||
s == gkey or (fam is not None and _genre_family(s) == fam)
|
||||
for s in gold_improv
|
||||
)
|
||||
badge = "gold" if gold else "earned"
|
||||
else:
|
||||
badge = "in_progress"
|
||||
# Practice invitation: the non-qualifying songs closest to the
|
||||
@@ -439,7 +479,11 @@ def _passports_view():
|
||||
"drills": {"required": required, "cleared": cleared},
|
||||
"badge": badge,
|
||||
})
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
|
||||
for p in passports:
|
||||
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports,
|
||||
"gig_count": len(inst_gigs)}
|
||||
return {
|
||||
"config": {
|
||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||
@@ -454,6 +498,96 @@ def _passports_view():
|
||||
}
|
||||
|
||||
|
||||
def _gig_config():
|
||||
cfg = _state["passports_content"].get("gig")
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
|
||||
def _num(key, default, cast):
|
||||
# Tuning data, not code: junk falls back instead of 500ing both gig
|
||||
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
|
||||
val = cfg.get(key)
|
||||
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
||||
return default
|
||||
return cast(val)
|
||||
|
||||
return {
|
||||
"min_songs": max(1, _num("min_songs", 3, int)),
|
||||
"max_songs": max(1, _num("max_songs", 5, int)),
|
||||
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
|
||||
"encore_accuracy": _num("encore_accuracy", 0.75, float),
|
||||
}
|
||||
|
||||
|
||||
def _current_venue():
|
||||
"""Highest unlocked venue (the room you can book today)."""
|
||||
stars_total, _, _ = _stars()
|
||||
best = None
|
||||
for v in _state["content"]["venues"]:
|
||||
if stars_total >= v["star_threshold"]:
|
||||
if best is None or v["star_threshold"] >= best["star_threshold"]:
|
||||
best = v
|
||||
return best
|
||||
|
||||
|
||||
def _fill_genre_songs(gkey, exclude, limit, tuning_ok=None):
|
||||
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||
set hasn't already picked.
|
||||
|
||||
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
|
||||
That restriction created a hole: a song you'd played on a DIFFERENT
|
||||
instrument's arrangement has a stats row, so it was excluded here — and it
|
||||
lives in the played bucket for THAT instrument, not this passport's, so it
|
||||
was excluded there too. It could never be gigged. A player with 137 metalcore
|
||||
songs, all played on another instrument, got a 404 (reproduced). The player's
|
||||
library is the pool; whether a song has stats on some other instrument has no
|
||||
bearing on whether it can be in THIS gig.
|
||||
|
||||
Shuffled, so re-roll actually changes the set. The old version returned the
|
||||
library's first N in table order every time, so re-roll was a no-op for any
|
||||
set drawn from the filler (reproduced).
|
||||
|
||||
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
|
||||
songs, single-user); push into SQL if propose ever feels slow.
|
||||
"""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g, tuning_name FROM songs"
|
||||
).fetchall()
|
||||
pool = [
|
||||
{"filename": fn, "title": title or fn, "artist": artist or "", "tuning_name": tn or ""}
|
||||
for fn, title, artist, genre, tn in rows
|
||||
if _genre_key(genre) == gkey and fn not in exclude
|
||||
and (tuning_ok is None or tuning_ok(tn or ""))
|
||||
]
|
||||
random.shuffle(pool) # re-roll must vary; free per call
|
||||
return pool[:limit]
|
||||
|
||||
|
||||
def _tuning_ok_fn(tuning_pref):
|
||||
"""Return a (tuning_name: str) -> bool callable for the given preference.
|
||||
|
||||
Returns None for 'any' (no filter). 'standard' matches names ending in
|
||||
' Standard'; 'drop' matches names containing 'Drop' (covers Drop D, Drop C,
|
||||
Double Drop D, etc.); 'specific:<name>' matches exact names. Unknown or
|
||||
malformed prefs treat as 'any' rather than hard-failing — a stale client
|
||||
request must not break gig booking.
|
||||
"""
|
||||
if not tuning_pref or tuning_pref == "any":
|
||||
return None
|
||||
if tuning_pref == "standard":
|
||||
return lambda n: bool(n) and n.endswith(" Standard")
|
||||
if tuning_pref == "drop":
|
||||
return lambda n: bool(n) and "Drop" in n
|
||||
if tuning_pref.startswith("specific:"):
|
||||
spec = tuning_pref[len("specific:"):]
|
||||
if not spec or len(spec) > 64:
|
||||
return None
|
||||
return lambda n, _s=spec: n == _s
|
||||
return None # unknown pref → no filter
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
@@ -561,7 +695,7 @@ def setup(app, context):
|
||||
"unlocked": stars_total >= v["star_threshold"],
|
||||
"installed": _installed(v["id"]),
|
||||
"bundled": _bundled(v["id"]),
|
||||
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
|
||||
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
|
||||
"download": dl,
|
||||
})
|
||||
return {
|
||||
@@ -625,23 +759,256 @@ def setup(app, context):
|
||||
# drops junk entries, which must not become a size-guard bypass.
|
||||
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
gold_in = body.get("goldImprov", {})
|
||||
if not isinstance(gold_in, dict):
|
||||
# A relay bug must be LOUD, not a silent 200 that drops gold.
|
||||
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
|
||||
# Keep only plausible artifacts: a dict that names its verifier —
|
||||
# an empty {} must not mint an evidence-free gold.
|
||||
gold_in = {k: v for k, v in gold_in.items()
|
||||
if isinstance(v, dict) and v.get("verifier")}
|
||||
# Same pre-merge bound byNode gets: the gained-only merge dropping
|
||||
# junk must not become a size-guard bypass (nor lock-held CPU burn).
|
||||
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
with _lock:
|
||||
_, existing = _drill_by_node()
|
||||
_, existing, existing_gold = _drill_by_node()
|
||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"])}
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"]),
|
||||
"goldImprov": _merge_gold(existing_gold, gold_in)}
|
||||
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||
"snapshot": snapshot})
|
||||
return {"ok": True}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
|
||||
def prepare_gig(body: dict = Body(...)):
|
||||
"""Unpack every song of the set BEFORE the gig starts.
|
||||
|
||||
A feedpak is a zip: the first play of one pays for its extraction into
|
||||
sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
|
||||
finished a number and then sat waiting for the next one to unpack, mid-
|
||||
gig. A set is a known list up front, so extract it all while the player
|
||||
is still looking at the poster.
|
||||
|
||||
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
|
||||
already-unpacked dir without rewriting it. Best-effort per song — one
|
||||
bad feedpak must not block the set from starting (the play itself will
|
||||
surface the error, exactly as it does outside a gig).
|
||||
"""
|
||||
raw = (body or {}).get("songs")
|
||||
# A str is iterable: without the list check, "abc" would prepare three
|
||||
# one-character "songs". Cap the count too — this endpoint unpacks zips,
|
||||
# so an oversized list is real work, and a setlist is a handful of songs.
|
||||
if not isinstance(raw, list):
|
||||
return {"ok": True, "prepared": 0, "failed": []}
|
||||
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
|
||||
if not files:
|
||||
return {"ok": True, "prepared": 0, "failed": []}
|
||||
|
||||
# .get, not []: a host that doesn't hand us the resolvers (or has no
|
||||
# library configured) must degrade to "extract lazily, as before" — this
|
||||
# is an optimisation, and it is never allowed to be the thing that stops
|
||||
# a gig from starting.
|
||||
get_dlc = context.get("get_dlc_dir")
|
||||
get_cache = context.get("get_sloppak_cache_dir")
|
||||
dlc_root = get_dlc() if callable(get_dlc) else None
|
||||
cache_root = get_cache() if callable(get_cache) else None
|
||||
if dlc_root is None or cache_root is None:
|
||||
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
|
||||
|
||||
root = Path(dlc_root)
|
||||
prepared, failed = 0, []
|
||||
for fn in files:
|
||||
# CONTAINMENT FIRST. resolve_source_dir() does a bare
|
||||
# `dlc_root / filename` with no guard, so a crafted `../..` would
|
||||
# walk straight out of the library. Every other filename-bound
|
||||
# handler validates through _resolve_dlc_path; so does this one.
|
||||
safe = _resolve_dlc_path(root, fn)
|
||||
if safe is None:
|
||||
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
|
||||
failed.append(fn)
|
||||
continue
|
||||
try:
|
||||
sloppak.resolve_source_dir(fn, root, Path(cache_root))
|
||||
prepared += 1
|
||||
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
|
||||
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
|
||||
failed.append(fn)
|
||||
return {"ok": True, "prepared": prepared, "failed": failed}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||
def propose_gig(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
tuning_pref = str((body or {}).get("tuning_pref") or "any")
|
||||
tuning_ok = _tuning_ok_fn(tuning_pref)
|
||||
cfg = _gig_config()
|
||||
try:
|
||||
size = int((body or {}).get("size") or 4)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "size must be a number.")
|
||||
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
||||
played, _seconds = _played_by_instrument_genre()
|
||||
stubs = list(played.get((inst, gkey), {}).values())
|
||||
# Annotate stubs with tuning_name (batch lookup to avoid N+1).
|
||||
if stubs and _state["meta_db"] is not None:
|
||||
fns = [s["filename"] for s in stubs]
|
||||
ph = ",".join("?" * len(fns))
|
||||
tn_rows = _state["meta_db"].conn.execute(
|
||||
f"SELECT filename, tuning_name FROM songs WHERE filename IN ({ph})", fns
|
||||
).fetchall()
|
||||
tn_by_file = {fn: (tn or "") for fn, tn in tn_rows}
|
||||
for s in stubs:
|
||||
s.setdefault("tuning_name", tn_by_file.get(s["filename"], ""))
|
||||
if tuning_ok is not None:
|
||||
stubs = [s for s in stubs if tuning_ok(s.get("tuning_name", ""))]
|
||||
req = _badge_requirement(gkey, inst)
|
||||
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
||||
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
||||
# The set: mostly songs you own, plus a couple of stakes songs near
|
||||
# the bar; a young passport fills from unplayed genre songs so the
|
||||
# first gig is how stubs start. random per call = free re-roll.
|
||||
random.shuffle(qualifying)
|
||||
rest.sort(key=lambda s: -s["best_accuracy"])
|
||||
qtaken = max(1, size - cfg["stakes_songs"])
|
||||
picks = qualifying[:qtaken]
|
||||
for s in rest:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
# Surplus qualifying songs backfill a short set — a mature passport
|
||||
# with no near-bar songs left must still fill the bill. Offset by how
|
||||
# many QUALIFYING songs were taken, not len(picks): rest's stakes
|
||||
# additions would otherwise skip eligible qualifying songs entirely.
|
||||
for s in qualifying[qtaken:]:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
if len(picks) < size:
|
||||
exclude = {s["filename"] for s in picks}
|
||||
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks), tuning_ok))
|
||||
if not picks:
|
||||
if tuning_pref and tuning_pref != "any":
|
||||
label = ("standard-tuning " if tuning_pref == "standard"
|
||||
else "drop-tuning " if tuning_pref == "drop"
|
||||
else f"“{tuning_pref[len('specific:'):]}”-tuning "
|
||||
if tuning_pref.startswith("specific:") else "")
|
||||
raise HTTPException(404, f"No {label}songs of this genre in the library.")
|
||||
raise HTTPException(404, "No songs of this genre in the library.")
|
||||
venue = _current_venue()
|
||||
return {
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"tuning_pref": tuning_pref,
|
||||
"venue_id": venue["id"] if venue else None,
|
||||
"venue_name": venue["name"] if venue else "",
|
||||
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
||||
"artist": s.get("artist") or "", "tuning_name": s.get("tuning_name") or ""}
|
||||
for s in picks[:size]],
|
||||
}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
||||
def log_gig(body: dict = Body(...)):
|
||||
# Called by the runner ONLY when the set completed — an abandoned set
|
||||
# never logs (no fail state; the gig you finished is the gig you
|
||||
# played). Accuracies come from song_stats, freshly written by the
|
||||
# set's own plays.
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
venue_id = str((body or {}).get("venue_id") or "")
|
||||
songs = (body or {}).get("songs")
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
|
||||
raise HTTPException(400, "Unknown venue.")
|
||||
if (not isinstance(songs, list) or not songs or len(songs) > 8
|
||||
or not all(isinstance(f, str) and f.strip() for f in songs)):
|
||||
raise HTTPException(400, "songs must be 1-8 filenames.")
|
||||
db = _state["meta_db"]
|
||||
entries = []
|
||||
accuracies = []
|
||||
for filename in songs:
|
||||
title = filename
|
||||
accuracy = None
|
||||
if db is not None:
|
||||
# The NEWEST row is the set's own just-recorded play — a
|
||||
# MAX(last_accuracy) across arrangements would happily log a
|
||||
# stale higher score from another instrument's old session.
|
||||
row = db.conn.execute(
|
||||
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
|
||||
"ORDER BY last_played_at DESC LIMIT 1",
|
||||
(filename,)).fetchone()
|
||||
if row and row[0] is not None:
|
||||
accuracy = round(float(row[0]), 4)
|
||||
accuracies.append(accuracy)
|
||||
trow = db.conn.execute(
|
||||
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
|
||||
if trow and trow[0]:
|
||||
title = trow[0]
|
||||
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
|
||||
# Encore needs the WHOLE set scored at the bar — one scored song must
|
||||
# not earn an encore for a set that was 4/5 unheard.
|
||||
encore = (len(accuracies) == len(songs) and
|
||||
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
|
||||
gig = {
|
||||
"at": _now_iso(),
|
||||
"venue_id": venue_id or None,
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"songs": entries,
|
||||
"encore": encore,
|
||||
}
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
if not isinstance(st.get("gigs"), list):
|
||||
st["gigs"] = []
|
||||
st["gigs"].append(gig)
|
||||
# ponytail: hard cap — nothing reads past the last 20 per
|
||||
# passport; the state file must not grow (and export) forever.
|
||||
st["gigs"] = st["gigs"][-500:]
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "gig": gig}
|
||||
|
||||
@app.get(f"/api/plugins/{PLUGIN_ID}/gigs/tunings")
|
||||
def gig_tunings(genre: str = ""):
|
||||
"""Distinct tuning names present in a genre's song pool, for the
|
||||
specific-tuning picker in the gig poster UI. Sorted by the library's
|
||||
own tuning_sort_key so the list matches the main library tuning filter."""
|
||||
gkey = _genre_key(_genre_display(genre)) if genre else ""
|
||||
if not gkey:
|
||||
return {"tunings": []}
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return {"tunings": []}
|
||||
rows = db.conn.execute(
|
||||
f"SELECT tuning_name, tuning_sort_key, {_genre_expr(db)} AS g "
|
||||
"FROM songs WHERE tuning_name != ''"
|
||||
).fetchall()
|
||||
seen: dict[str, int] = {}
|
||||
for tn, sk, genre_raw in rows:
|
||||
if _genre_key(genre_raw) == gkey and tn and tn not in seen:
|
||||
seen[tn] = sk or 0
|
||||
return {"tunings": sorted(seen.keys(), key=lambda n: (seen[n], n))}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||
def start_download(venue_id: str):
|
||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||
if venue is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
pack = venue.get("pack")
|
||||
if not pack:
|
||||
if not _pack_published(pack):
|
||||
raise HTTPException(404, "No pack published for this venue yet.")
|
||||
stars_total, _, _ = _stars()
|
||||
if stars_total < venue["star_threshold"]:
|
||||
|
||||
+808
-25
@@ -12,6 +12,10 @@
|
||||
'use strict';
|
||||
|
||||
const API = '/api/plugins/career';
|
||||
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
|
||||
// so this is generous — but it is a CEILING, not a wait. Past it we start the
|
||||
// gig and let the first play extract lazily, as it always did.
|
||||
const PREPARE_TIMEOUT_MS = 60000;
|
||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||
const NO_VENUE = '__none__';
|
||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||
@@ -22,6 +26,7 @@
|
||||
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
||||
const PP_INST_KEY = 'feedBack-career-instrument';
|
||||
const PP_TAB_KEY = 'feedBack-career-tab';
|
||||
const PP_TUNING_PREF_KEY = 'feedBack-career-tuning-pref';
|
||||
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
|
||||
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
||||
|
||||
@@ -38,6 +43,13 @@
|
||||
let _ppCeremonyActive = false;
|
||||
let _ppBootstrapped = false;
|
||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||
let _ppGigProposal = null; // the booking poster's proposal, while open
|
||||
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, tuning_pref, idx} mid-set
|
||||
let _ppGigTuningPref = 'any'; // loaded from localStorage in boot()
|
||||
let _ppGigTuningNames = []; // cached distinct tuning names for the specific picker
|
||||
let _ppGigTuningHold = null; // holdAutoplay release fn — non-null = interstitial active
|
||||
let _ppGigLastTuning = null; // tuning_name of the current gig song (for change detection)
|
||||
let _ppBookGen = 0; // generation counter — stale responses are discarded
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -302,11 +314,15 @@
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
|
||||
function badgeId(inst, gkey) { return inst + '/' + gkey; }
|
||||
// Bronze keeps the legacy un-suffixed id, so badges seen before the Gold
|
||||
// tier existed stay seen; gold is a distinct moment with its own id.
|
||||
function badgeId(inst, gkey, tier) { return inst + '/' + gkey + (tier === 'gold' ? '@gold' : ''); }
|
||||
|
||||
function markBadgeSeen(inst, gkey) {
|
||||
function markBadgeSeen(inst, gkey, tier) {
|
||||
const seen = seenBadges();
|
||||
seen[badgeId(inst, gkey)] = 1;
|
||||
seen[badgeId(inst, gkey, tier)] = 1;
|
||||
// A gold slam covers the bronze moment too — never queue both.
|
||||
if (tier === 'gold') seen[badgeId(inst, gkey)] = 1;
|
||||
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||
}
|
||||
|
||||
@@ -318,15 +334,19 @@
|
||||
const seen = seenBadges();
|
||||
for (const inst of Object.keys(view.instruments || {})) {
|
||||
for (const p of (view.instruments[inst].passports || [])) {
|
||||
const id = badgeId(inst, p.genre_key);
|
||||
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
|
||||
if (p.badge !== 'earned' && p.badge !== 'gold') continue;
|
||||
const gold = p.badge === 'gold';
|
||||
const id = badgeId(inst, p.genre_key, p.badge);
|
||||
if (seen[id] || _ppNotified[id]) continue;
|
||||
_ppNotified[id] = true;
|
||||
sfx('chime');
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({
|
||||
big: true, icon: '🛂', accent: '#b45309',
|
||||
title: 'Badge earned!',
|
||||
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||
big: true, icon: gold ? '🏅' : '🛂', accent: gold ? '#d9a253' : '#b45309',
|
||||
title: gold ? 'Gold — a verified improv!' : 'Badge earned!',
|
||||
message: gold
|
||||
? `${p.genre} — your ${ppLabel(inst)} badge turns gold.`
|
||||
: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||
});
|
||||
}
|
||||
badgeCeremony(inst, p);
|
||||
@@ -375,11 +395,11 @@
|
||||
el.innerHTML = `
|
||||
<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-ceremony-card">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
<span class="pp-stamp-tier">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>
|
||||
</div>
|
||||
<div class="pp-ceremony-title">Badge earned</div>
|
||||
<div class="pp-ceremony-title">${p.badge === 'gold' ? 'Gold — a verified improv' : 'Badge earned'}</div>
|
||||
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||||
</div>`;
|
||||
let timer = 0;
|
||||
@@ -443,7 +463,11 @@
|
||||
fetch(`${API}/drill-state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
|
||||
body: JSON.stringify({
|
||||
mode: snap.mode, xp: snap.xp, byNode: snap.byNode,
|
||||
...(snap.goldImprov && typeof snap.goldImprov === 'object' && !Array.isArray(snap.goldImprov)
|
||||
? { goldImprov: snap.goldImprov } : {}),
|
||||
}),
|
||||
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
||||
}, 1500);
|
||||
}
|
||||
@@ -458,6 +482,8 @@
|
||||
_pp = view;
|
||||
detectNewBadges(view);
|
||||
renderPassports();
|
||||
renderProfileWall();
|
||||
renderDashCard();
|
||||
if (!_ppBootstrapped) {
|
||||
_ppBootstrapped = true;
|
||||
// Sync the local drill snapshot once per session — drill progress
|
||||
@@ -479,9 +505,9 @@
|
||||
|
||||
function ppCoverHTML(inst, p) {
|
||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||
const earned = p.badge === 'earned';
|
||||
const earned = p.badge === 'earned' || p.badge === 'gold';
|
||||
const stamp = earned
|
||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||
? `<span class="pp-stamp pp-stamp-mini${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>`
|
||||
: '';
|
||||
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
@@ -595,7 +621,7 @@
|
||||
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
||||
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
||||
const d = (_pp.instruments || {})[i] || {};
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold').length;
|
||||
const committed = !!d.committed_at;
|
||||
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||
@@ -637,13 +663,19 @@
|
||||
let badgeArea = '';
|
||||
if (p.badge === 'shown_not_judged') {
|
||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||
} else if (p.badge === 'earned') {
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
} else if (p.badge === 'earned' || p.badge === 'gold') {
|
||||
const gold = p.badge === 'gold';
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}${gold ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
<span class="pp-stamp-tier">${gold ? 'GOLD' : 'BRONZE'}</span>
|
||||
</div>
|
||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||
${gold
|
||||
? '<div class="pp-gold-foil" aria-hidden="true">GOLD</div><div class="pp-gold-note">A verified improv — the comb heard it live.</div>'
|
||||
: '<div class="pp-gold-note">Gold rung: improvise over this style in a Virtuoso jam — verified, not self-reported.</div>'}
|
||||
<div class="pp-card-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
||||
</div>`;
|
||||
} else {
|
||||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||
@@ -680,15 +712,24 @@
|
||||
`<div class="pp-nearest-row"><em>${esc(s.title)}</em> — best ${pct(s.best_accuracy)}%, ${starGl} at ${pct(s.bar_at)}%</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
let gigLog = '';
|
||||
if ((p.gigs || []).length) {
|
||||
gigLog = `<div class="pp-giglog">
|
||||
<div class="pp-giglog-head">Gigs played</div>
|
||||
${p.gigs.slice(0, 6).map((g) =>
|
||||
`<div class="pp-giglog-row">${esc((g.at || '').slice(0, 10))} · ${esc(_venueName(g.venue_id))}${g.encore ? ' · <b>encore</b>' : ''}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
|
||||
<div class="pp-book">
|
||||
<div class="pp-page pp-page-left">
|
||||
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||||
${badgeArea}${odometer}${drills}
|
||||
<button class="career-btn career-btn-primary pp-gig-book" data-pp-gig="${esc(p.genre_key)}">Book a gig</button>
|
||||
</div>
|
||||
<div class="pp-page pp-page-right">
|
||||
<div class="pp-page-head">Ticket stubs</div>
|
||||
<div class="pp-stubs">${stubsHTML}${nearest}</div>
|
||||
<div class="pp-stubs">${stubsHTML}${nearest}${gigLog}</div>
|
||||
</div>
|
||||
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||
@@ -707,7 +748,8 @@
|
||||
if (!p || !overlay) return;
|
||||
_ppBook = { inst, gkey };
|
||||
_ppReturnFocus = document.activeElement;
|
||||
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
|
||||
const pending = (p.badge === 'earned' || p.badge === 'gold')
|
||||
&& !seenBadges()[badgeId(inst, gkey, p.badge)];
|
||||
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
||||
overlay.classList.remove('hidden');
|
||||
const close = overlay.querySelector('.pp-book-close');
|
||||
@@ -729,7 +771,7 @@
|
||||
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||
if (book) book.classList.add('pp-shake');
|
||||
sfx('stamp');
|
||||
markBadgeSeen(inst, gkey);
|
||||
markBadgeSeen(inst, gkey, p.badge);
|
||||
renderPassports(); // the shelf cover gains its mini-stamp
|
||||
}, 950);
|
||||
}
|
||||
@@ -737,6 +779,8 @@
|
||||
|
||||
function closeBook() {
|
||||
_ppBook = null;
|
||||
_ppGigProposal = null; // a dismissed poster is a dismissed booking
|
||||
++_ppBookGen; // invalidate any in-flight bookGig request
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||
@@ -823,6 +867,670 @@
|
||||
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||
}
|
||||
|
||||
// ── Shareable passport card (canvas → PNG, save or clipboard) ─────────
|
||||
// Keep in sync with the .pp-leather-* gradients in assets/career.css —
|
||||
// canvas can't consume a CSS class, so the pairs live twice on purpose.
|
||||
const PP_LEATHER_HEX = {
|
||||
guitar: ['#5c2321', '#401412'],
|
||||
bass: ['#1f3252', '#131f36'],
|
||||
keys: ['#1e4034', '#122a21'],
|
||||
drums: ['#3f3f46', '#26262b'],
|
||||
};
|
||||
|
||||
function drawPassportCard(inst, p) {
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar;
|
||||
const bg = ctx.createLinearGradient(0, 0, W, H);
|
||||
bg.addColorStop(0, c1);
|
||||
bg.addColorStop(1, c2);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
// Emboss frame
|
||||
ctx.strokeStyle = 'rgba(240,226,195,0.35)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(18, 18, W - 36, H - 36);
|
||||
// Genre title
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.95)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = '700 34px Georgia, serif';
|
||||
ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80);
|
||||
ctx.font = '400 15px Georgia, serif';
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.55)';
|
||||
ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122);
|
||||
// Stamp ring
|
||||
const gold = p.badge === 'gold';
|
||||
const ink = gold ? '#d9a253' : '#b06a2a';
|
||||
const cy = 330;
|
||||
ctx.strokeStyle = ink;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, cy, 118, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, cy, 106, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = ink;
|
||||
ctx.font = '800 26px Georgia, serif';
|
||||
ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190);
|
||||
ctx.font = '600 16px Georgia, serif';
|
||||
ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28);
|
||||
// Facts
|
||||
const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.75)';
|
||||
ctx.font = '400 17px Georgia, serif';
|
||||
ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512);
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||
ctx.font = '400 13px Georgia, serif';
|
||||
ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// One export path for every canvas artifact: copy (with download
|
||||
// fallback + notice) or save, failures audible.
|
||||
function exportCanvasPng(canvas, filename, mode, noun) {
|
||||
canvas.toBlob(async (blob) => {
|
||||
const fail = (why) => {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: `${noun} export failed`, message: why });
|
||||
};
|
||||
if (!blob) { fail('The canvas produced no image.'); return; }
|
||||
try {
|
||||
const io = await import('/static/js/blob-io.js');
|
||||
if (mode === 'copy') {
|
||||
const ok = await io.copyImageBlob(blob);
|
||||
if (ok) {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: `${noun} copied`, message: 'Paste it anywhere.' });
|
||||
return;
|
||||
}
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: `Saved the ${noun.toLowerCase()} instead.` });
|
||||
}
|
||||
io.downloadBlob(blob, filename);
|
||||
} catch (e) { fail('Export helper unavailable.'); }
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
function exportPassportCard(mode) {
|
||||
if (!_ppBook || !_pp) return;
|
||||
const { inst, gkey } = _ppBook;
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
const canvas = drawPassportCard(inst, p);
|
||||
exportCanvasPng(canvas, `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Card');
|
||||
}
|
||||
|
||||
// ── Career surfaces outside the plugin screen ─────────────────────────
|
||||
// Profile passport wall + the home-page career card. Both inject into
|
||||
// core-owned mounts announced by v3:profile-rendered /
|
||||
// v3:dashboard-rendered (the achievements seam). Absent-not-empty: with
|
||||
// no committed instrument they render nothing and the dashboard keeps
|
||||
// its built-in fallback stat.
|
||||
|
||||
function careerTotals() {
|
||||
if (!_pp) return null;
|
||||
let badges = 0;
|
||||
let seconds = 0;
|
||||
let gigs = 0;
|
||||
const walls = [];
|
||||
for (const inst of (_pp.config || {}).instruments || []) {
|
||||
const d = (_pp.instruments || {})[inst];
|
||||
// A commitment with no opened passport isn't a wall yet — the
|
||||
// external surfaces (profile, home card) stay ABSENT until a
|
||||
// passport exists (absent-not-empty).
|
||||
if (!d || !d.committed_at || !(d.passports || []).length) continue;
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold');
|
||||
badges += earned.length;
|
||||
seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0);
|
||||
gigs += d.gig_count || 0;
|
||||
walls.push({ inst, earned, opened: d.passports.length });
|
||||
}
|
||||
if (!walls.length) return null;
|
||||
return { badges, seconds, gigs, walls };
|
||||
}
|
||||
|
||||
function closestAskHTML() {
|
||||
if (!_pp) return '';
|
||||
let best = null;
|
||||
for (const inst of (_pp.config || {}).instruments || []) {
|
||||
for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) {
|
||||
if (p.badge !== 'in_progress') continue;
|
||||
const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count);
|
||||
if (!best || need < best.need) best = { p, need };
|
||||
}
|
||||
}
|
||||
if (!best) return '';
|
||||
const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0);
|
||||
if (best.need > 0) {
|
||||
return `${esc(best.p.genre)} — ${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`;
|
||||
}
|
||||
return `${esc(best.p.genre)} — one drill away`;
|
||||
}
|
||||
|
||||
function renderProfileWall() {
|
||||
const mount = document.getElementById('v3-profile-passports-mount');
|
||||
if (!mount) return;
|
||||
const totals = careerTotals();
|
||||
if (!totals) { mount.innerHTML = ''; return; }
|
||||
const shelves = totals.walls.map(({ inst, earned, opened }) => {
|
||||
const covers = earned.map((p) =>
|
||||
`<button class="pp-wall-cover pp-leather-${esc(inst)}" data-pp-wall-inst="${esc(inst)}" data-pp-wall-gkey="${esc(p.genre_key)}" title="${esc(p.genre)}">
|
||||
<span>${esc(p.genre.toUpperCase())}</span>
|
||||
<em>${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</em>
|
||||
</button>`).join('');
|
||||
const line = earned.length
|
||||
? covers
|
||||
: `<span class="pp-wall-none">${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending</span>`;
|
||||
return `<div class="pp-wall-shelf"><span class="pp-wall-inst">${esc(ppLabel(inst))}</span>${line}</div>`;
|
||||
}).join('');
|
||||
const hours = fmtHours(totals.seconds);
|
||||
mount.innerHTML = `<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 pp-wall">
|
||||
<div class="pp-wall-head">
|
||||
<span>Passport wall</span>
|
||||
<span class="pp-wall-meta">${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||
</div>
|
||||
${shelves}
|
||||
<button class="pp-wall-link" data-pp-wall-career="1">Open career →</button>
|
||||
</div>`;
|
||||
if (!mount.dataset.ppWired) {
|
||||
mount.dataset.ppWired = '1';
|
||||
mount.addEventListener('click', onWallClick);
|
||||
}
|
||||
}
|
||||
|
||||
function onWallClick(e) {
|
||||
const open = e.target.closest('[data-pp-wall-inst]');
|
||||
if (open) {
|
||||
// Two attributes, not a '/'-joined pair: a genre key may itself
|
||||
// contain '/' ("drum/bass") and must round-trip intact.
|
||||
const inst = open.dataset.ppWallInst;
|
||||
const gkey = open.dataset.ppWallGkey;
|
||||
lsSet(PP_INST_KEY, inst);
|
||||
if (window.showScreen) window.showScreen('plugin-career');
|
||||
showCareerTab('passports');
|
||||
renderPassports();
|
||||
openBook(inst, gkey);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-pp-wall-career]')) {
|
||||
if (window.showScreen) window.showScreen('plugin-career');
|
||||
showCareerTab('passports');
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashCard() {
|
||||
const slot = document.getElementById('v3-dash-career-slot');
|
||||
if (!slot) return;
|
||||
const totals = careerTotals();
|
||||
if (!totals) return; // keep core's fallback stat card
|
||||
const hours = fmtHours(totals.seconds);
|
||||
const ask = closestAskHTML();
|
||||
slot.innerHTML = `<button class="pp-dash-card" data-pp-wall-career="1">
|
||||
<span class="pp-dash-shine" aria-hidden="true"></span>
|
||||
<span class="pp-dash-head">Career</span>
|
||||
<span class="pp-dash-badges">${'⚡'.repeat(Math.min(totals.badges, 5))}<b>${totals.badges}</b> badge${totals.badges === 1 ? '' : 's'}</span>
|
||||
<span class="pp-dash-meta">${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||
${ask ? `<span class="pp-dash-ask">closest: ${ask}</span>` : ''}
|
||||
</button>`;
|
||||
if (!slot.dataset.ppWired) {
|
||||
slot.dataset.ppWired = '1';
|
||||
slot.addEventListener('click', onWallClick);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gigs: booking poster → set runner → summary ──────────────────────
|
||||
|
||||
function _venueName(venueId) {
|
||||
const v = (_state && _state.venues || []).find((x) => x.id === venueId);
|
||||
return v ? v.name : (venueId || 'the stage');
|
||||
}
|
||||
|
||||
function gigPosterHTML(prop) {
|
||||
const bill = prop.songs.map((s, i) =>
|
||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}${s.tuning_name ? ` <small class="pp-poster-tuning-chip">${esc(s.tuning_name)}</small>` : ''}</div>`).join('');
|
||||
const PREF_LABELS = { any: 'Any', standard: 'Standard', drop: 'Drop', specific: 'Specific…' };
|
||||
const isSpecific = _ppGigTuningPref.startsWith('specific:');
|
||||
const curPill = isSpecific ? 'specific' : _ppGigTuningPref;
|
||||
const pills = Object.keys(PREF_LABELS).map((p) =>
|
||||
`<button data-pp-tuning="${esc(p)}" class="career-btn career-btn-ghost pp-tuning-pill${curPill === p ? ' pp-tuning-pill-on' : ''}">${PREF_LABELS[p]}</button>`
|
||||
).join('');
|
||||
const selVal = isSpecific ? _ppGigTuningPref.slice('specific:'.length) : '';
|
||||
const selOpts = _ppGigTuningNames.length
|
||||
? `<option value="">Choose tuning…</option>${_ppGigTuningNames.map((n) => `<option value="${esc(n)}"${n === selVal ? ' selected' : ''}>${esc(n)}</option>`).join('')}`
|
||||
: `<option value="">Loading…</option>`;
|
||||
const selHidden = isSpecific ? '' : ' hidden';
|
||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
|
||||
<div class="pp-poster">
|
||||
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
|
||||
<div class="pp-poster-presents">presents</div>
|
||||
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
|
||||
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
|
||||
<div class="pp-tuning-row">${pills}<select data-pp-tuning-select class="pp-tuning-select${selHidden}">${selOpts}</select></div>
|
||||
<div class="pp-poster-bill">${bill}</div>
|
||||
<div class="pp-poster-actions">
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-gig-reroll="1">Re-roll</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy</button>
|
||||
</div>
|
||||
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function bookGig(gkey) {
|
||||
if (!_pp) return;
|
||||
const inst = activeInstrument();
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
const gen = ++_ppBookGen; // F1: capture generation before await — stale responses discarded
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs/propose`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instrument: inst, genre: p.genre, tuning_pref: _ppGigTuningPref }),
|
||||
});
|
||||
if (gen !== _ppBookGen) return; // stale response — a newer request supersedes this one
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
if (gen !== _ppBookGen) return; // stale — superseded while awaiting error json()
|
||||
if (res.status === 404) {
|
||||
// No-match 404: revert tuning pref to 'any', re-render poster to match
|
||||
_ppGigTuningPref = 'any';
|
||||
lsSet(PP_TUNING_PREF_KEY, 'any');
|
||||
if (_ppGigProposal) {
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||
}
|
||||
}
|
||||
// All errors: notify (404 has a tuning-specific message; others are generic)
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
const msg = res.status === 404
|
||||
? (err && err.detail) || 'No songs match that tuning filter.'
|
||||
: 'Could not book gig — please try again.';
|
||||
try { window.fbNotify.show({ title: 'Gig booking', message: msg, icon: '🎸' }); } catch (_) { /* */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ppGigProposal = await res.json();
|
||||
} catch (_) { return; }
|
||||
if (gen !== _ppBookGen) return; // stale — superseded while awaiting json()
|
||||
const overlay = $('pp-overlay');
|
||||
if (!overlay) return;
|
||||
_ppBook = null; // the poster replaces the book in the overlay
|
||||
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||
overlay.classList.remove('hidden');
|
||||
sfx('page');
|
||||
}
|
||||
|
||||
async function _openSpecificTuningPicker() {
|
||||
const overlay = $('pp-overlay');
|
||||
if (!overlay || !_ppGigProposal) return;
|
||||
if (_ppGigTuningNames.length) {
|
||||
const sel = overlay.querySelector('[data-pp-tuning-select]');
|
||||
if (sel) sel.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs/tunings?genre=${encodeURIComponent(_ppGigProposal.genre)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
_ppGigTuningNames = Array.isArray(data.tunings) ? data.tunings : [];
|
||||
} catch (_) { return; }
|
||||
// Re-render with populated options
|
||||
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||
const sel = overlay.querySelector('[data-pp-tuning-select]');
|
||||
if (sel) sel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Unpack the whole set before the first note.
|
||||
//
|
||||
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
||||
// a set that cost landed BETWEEN songs: the player finished a number and
|
||||
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
|
||||
// known up front, so warm it all while the poster is still on screen.
|
||||
//
|
||||
// Best-effort by design: a library that won't pre-extract must not stop the
|
||||
// gig from starting — the play itself surfaces the error the same way it
|
||||
// does outside a gig. Slow is better than blocked.
|
||||
async function prepareGigSongs(prop, btn) {
|
||||
const label = btn && btn.textContent;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
|
||||
// A bare `await fetch(...)` only rejects on a network ERROR — a server
|
||||
// that accepts the connection and then never answers hangs forever, and
|
||||
// the gig would never start. That would make this optimisation the very
|
||||
// thing it promises never to be: the reason you cannot play. Give up
|
||||
// waiting and let the first play extract lazily, exactly as before.
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
|
||||
try {
|
||||
await fetch(`${API}/gigs/prepare`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (_) {
|
||||
// abort, offline, non-2xx — all the same: start the gig anyway.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
|
||||
}
|
||||
}
|
||||
|
||||
async function startGig(btn) {
|
||||
const prop = _ppGigProposal;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
|
||||
|
||||
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
|
||||
// so a failure here leaves nothing half-applied to unwind.
|
||||
await prepareGigSongs(prop, btn);
|
||||
// The poster's Play could have been cancelled while we were unpacking.
|
||||
if (_ppGigProposal !== prop) return;
|
||||
|
||||
// The gig BORROWS the stage: stash whatever venue/viz the user had so
|
||||
// the set ending gives it back (unlike "Play here", which is an
|
||||
// explicit persistent choice on the venue card).
|
||||
let restore = null;
|
||||
if (prop.venue_id) {
|
||||
// Capture the restore snapshot BEFORE any write: if a later write
|
||||
// (or setViz) throws, the stage must still be returnable.
|
||||
try {
|
||||
restore = {
|
||||
venue: localStorage.getItem(VENUE_OVERRIDE_KEY),
|
||||
viz: localStorage.getItem('vizSelection'),
|
||||
};
|
||||
} catch (_) { restore = null; }
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, prop.venue_id);
|
||||
localStorage.setItem('vizSelection', 'venue');
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* viz optional — restore stays intact */ }
|
||||
}
|
||||
// Push the gig's venue pack to the crowd layer NOW.
|
||||
//
|
||||
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
|
||||
// and pushCrowdManifest is called only from refresh() — the career
|
||||
// tab's own reload. A gig navigates AWAY from the career tab to the
|
||||
// player, so refresh() never runs during it, and setting the override
|
||||
// above does nothing on its own. The result the testers saw: the venue
|
||||
// visualization turns on (3D highway) but its crowd/stage pack never
|
||||
// loads, so the song plays over the bare highway backdrop ("standard
|
||||
// particles"), or over whatever venue a previous refresh() happened to
|
||||
// leave applied. We just changed the override to this gig's venue, so
|
||||
// re-push for it. _state is the career state the booking screen already
|
||||
// fetched; guard for the rare null.
|
||||
_appliedManifestVenue = null;
|
||||
if (_state) pushCrowdManifest(_state);
|
||||
_ppGigRun = {
|
||||
songs: prop.songs,
|
||||
venue_id: prop.venue_id,
|
||||
genre: prop.genre,
|
||||
genre_key: prop.genre_key,
|
||||
instrument: prop.instrument,
|
||||
tuning_pref: prop.tuning_pref || 'any',
|
||||
idx: 0,
|
||||
restore,
|
||||
};
|
||||
_ppGigLastTuning = null; // reset for fresh interstitial tracking
|
||||
closeBook();
|
||||
_ppGigProposal = null;
|
||||
// RAW filenames: the queue itself encodes for playSong — pre-encoding
|
||||
// double-encodes and breaks loading + the stats/gig filename join.
|
||||
if (!q.start(prop.songs.map((s) => s.filename), { source: 'gig' })) {
|
||||
_ppGigRun = null;
|
||||
return;
|
||||
}
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function restoreGigStage(run) {
|
||||
const r = run && run.restore;
|
||||
if (!r) return;
|
||||
try {
|
||||
if (r.venue == null) localStorage.removeItem(VENUE_OVERRIDE_KEY);
|
||||
else localStorage.setItem(VENUE_OVERRIDE_KEY, r.venue);
|
||||
if (r.viz && r.viz !== 'venue') {
|
||||
localStorage.setItem('vizSelection', r.viz);
|
||||
if (typeof window.setViz === 'function') window.setViz(r.viz);
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
_appliedManifestVenue = null;
|
||||
}
|
||||
|
||||
function renderGigStrip() {
|
||||
if (!_ppGigRun || !document.body || typeof document.createElement !== 'function') return;
|
||||
let strip = document.getElementById('pp-gig-strip');
|
||||
if (!strip) {
|
||||
strip = document.createElement('div');
|
||||
strip.id = 'pp-gig-strip';
|
||||
strip.className = 'pp-gig-strip';
|
||||
document.body.appendChild(strip);
|
||||
}
|
||||
const run = _ppGigRun;
|
||||
if (_ppGigTuningHold) {
|
||||
const song = run.songs[run.idx];
|
||||
const tuning = (song && song.tuning_name) ? esc(song.tuning_name) : 'check tuning';
|
||||
strip.innerHTML = `<b class="pp-gig-tune-label">Tune to: ${tuning}</b><button data-pp-gig-start-song="1" class="career-btn career-btn-primary pp-gig-start-btn">Start song</button>`;
|
||||
} else {
|
||||
const next = run.songs[run.idx + 1];
|
||||
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
|
||||
}
|
||||
}
|
||||
|
||||
function removeGigStrip() {
|
||||
const strip = document.getElementById('pp-gig-strip');
|
||||
if (strip) strip.remove();
|
||||
}
|
||||
|
||||
function abandonGig() {
|
||||
// No fail state: an abandoned set logs nothing and says nothing.
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
_ppGigLastTuning = null;
|
||||
if (_ppGigTuningHold) { const h = _ppGigTuningHold; _ppGigTuningHold = null; h(); }
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
}
|
||||
|
||||
function completeGig() {
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
// The final song's own stats POST races this moment (both ride
|
||||
// song:ended): wait for its stats:recorded — or a short timeout, since
|
||||
// an UNSCORED play never emits one — so /gigs reads the set's real
|
||||
// accuracies, not last week's.
|
||||
const lastFile = run.songs[run.songs.length - 1].filename;
|
||||
const sm = window.feedBack;
|
||||
let done = false;
|
||||
const proceed = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (sm && typeof sm.off === 'function') { try { sm.off('stats:recorded', onRec); } catch (_) { /* ok */ } }
|
||||
postGig(run);
|
||||
};
|
||||
const onRec = (e) => {
|
||||
const d = (e && e.detail) || {};
|
||||
if (d.filename === lastFile) proceed();
|
||||
};
|
||||
if (sm && typeof sm.on === 'function') sm.on('stats:recorded', onRec);
|
||||
setTimeout(proceed, 3500);
|
||||
}
|
||||
|
||||
async function postGig(run) {
|
||||
let gig = null;
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
instrument: run.instrument,
|
||||
genre: run.genre,
|
||||
venue_id: run.venue_id,
|
||||
songs: run.songs.map((s) => s.filename),
|
||||
}),
|
||||
});
|
||||
if (res.ok) gig = (await res.json()).gig;
|
||||
} catch (_) { /* summary still shows, unlogged */ }
|
||||
showGigSummary(run, gig);
|
||||
refreshPassports();
|
||||
}
|
||||
|
||||
function showGigSummary(run, gig) {
|
||||
if (!document.body || typeof document.createElement !== 'function') return;
|
||||
const entries = (gig && gig.songs) || run.songs.map((s) => ({ filename: s.filename, title: s.title, accuracy: null }));
|
||||
const encore = !!(gig && gig.encore);
|
||||
if (encore && !reducedMotion()) {
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (crowd && typeof crowd.celebrate === 'function') {
|
||||
try { crowd.celebrate(); } catch (_) { /* optional */ }
|
||||
}
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pp-gig-summary';
|
||||
el.className = 'pp-ceremony-overlay';
|
||||
el.innerHTML = `<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-poster pp-poster-summary">
|
||||
<div class="pp-poster-venue">${esc(_venueName(run.venue_id))}</div>
|
||||
<div class="pp-poster-title">${esc(run.genre.toUpperCase())} NIGHT</div>
|
||||
<div class="pp-poster-inst">${encore ? 'ENCORE! ' : ''}the set, as played</div>
|
||||
<div class="pp-poster-bill">${entries.map((s, i) =>
|
||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.accuracy != null ? ` <b>${Math.floor(s.accuracy * 100)}%</b>` : ''}</div>`).join('')}</div>
|
||||
<div class="pp-poster-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save poster</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy poster</button>
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-done="1">Done</button>
|
||||
</div>
|
||||
</div>`;
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el || e.target.closest('[data-pp-gig-done]')) {
|
||||
el.remove();
|
||||
} else if (e.target.closest('[data-pp-poster]')) {
|
||||
exportGigPoster(e.target.closest('[data-pp-poster]').dataset.ppPoster,
|
||||
{ ...run, encore, entries });
|
||||
}
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
if (encore && !reducedMotion()) confettiBurst(el.querySelector('.pp-confetti'));
|
||||
}
|
||||
|
||||
function drawGigPoster(data) {
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, H);
|
||||
bg.addColorStop(0, '#141019');
|
||||
bg.addColorStop(1, '#241318');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = 'rgba(217,162,83,0.5)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(16, 16, W - 32, H - 32);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.65)';
|
||||
ctx.font = '400 18px Georgia, serif';
|
||||
ctx.fillText(_venueName(data.venue_id), W / 2, 76, W - 80);
|
||||
ctx.font = '400 12px Georgia, serif';
|
||||
ctx.fillText('P R E S E N T S', W / 2, 102);
|
||||
ctx.fillStyle = '#d9a253';
|
||||
ctx.font = '800 40px Georgia, serif';
|
||||
ctx.fillText(`${data.genre.toUpperCase()}`, W / 2, 160, W - 60);
|
||||
ctx.font = '800 26px Georgia, serif';
|
||||
ctx.fillText('NIGHT', W / 2, 194);
|
||||
if (data.encore) {
|
||||
ctx.fillStyle = '#f3d179';
|
||||
ctx.font = '700 16px Georgia, serif';
|
||||
ctx.fillText('— E N C O R E —', W / 2, 226);
|
||||
}
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.85)';
|
||||
ctx.font = '400 18px Georgia, serif';
|
||||
const entries = data.entries || data.songs || [];
|
||||
entries.slice(0, 6).forEach((sng, i) => {
|
||||
const acc = sng.accuracy != null ? ` · ${Math.floor(sng.accuracy * 100)}%` : '';
|
||||
ctx.fillText(`${sng.title}${acc}`, W / 2, 290 + i * 44, W - 80);
|
||||
});
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||
ctx.font = '400 13px Georgia, serif';
|
||||
ctx.fillText(`${ppLabel(data.instrument || 'guitar')} · fee[dB]ack career`, W / 2, H - 42);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function exportGigPoster(mode, data) {
|
||||
exportCanvasPng(drawGigPoster(data),
|
||||
`gig-${(data.genre_key || 'set').replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Poster');
|
||||
}
|
||||
|
||||
// Queue lifecycle: advance the strip per song; complete or abandon.
|
||||
function onGigSongLoading() {
|
||||
if (!_ppGigRun) return;
|
||||
const run = _ppGigRun;
|
||||
const song = run.songs[run.idx];
|
||||
const tuningName = (song && song.tuning_name) || '';
|
||||
const pref = run.tuning_pref || 'any';
|
||||
// Interstitial: pause before first song (or when tuning changes) for all
|
||||
// non-specific prefs, so the player has time to retune. "specific" is
|
||||
// excluded because every song already matches one fixed tuning.
|
||||
const needsInterstitial = !pref.startsWith('specific:') && (
|
||||
run.idx === 0 || tuningName !== _ppGigLastTuning
|
||||
);
|
||||
_ppGigLastTuning = tuningName;
|
||||
if (needsInterstitial) {
|
||||
const fb = window.feedBack;
|
||||
const holdFn = fb && typeof fb.holdAutoplay === 'function' ? fb.holdAutoplay : null;
|
||||
_ppGigTuningHold = holdFn ? holdFn() : null;
|
||||
if (_ppGigTuningHold) {
|
||||
// Cancel the fail-open backstop — we manage the dismiss ourselves
|
||||
// (user clicks "Start song"). A song navigation clears the hold anyway.
|
||||
_ppGigTuningHold.settle();
|
||||
}
|
||||
if (_ppGigTuningHold && window.tuner && typeof window.tuner.enable === 'function') {
|
||||
window.tuner.enable({ auto: true }).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
_ppGigTuningHold = null;
|
||||
}
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function onGigSongEnded() {
|
||||
if (!_ppGigRun) return;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!q || typeof q.remaining !== 'function') return;
|
||||
// Only OUR live queue counts: remaining()===0 is also true for a
|
||||
// cleared/foreign queue (a manual play silently clears the gig queue,
|
||||
// and that unrelated song's end must not log a gig).
|
||||
if (q.source && q.source() !== 'gig') { abandonGig(); return; }
|
||||
if (!q.remaining()) {
|
||||
if (q.active && q.active()) completeGig();
|
||||
else abandonGig();
|
||||
return;
|
||||
}
|
||||
_ppGigRun.idx = Math.min(_ppGigRun.idx + 1, _ppGigRun.songs.length - 1);
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function onGigSongStop() {
|
||||
// A deliberate quit mid-set (Escape clears the queue) abandons the
|
||||
// gig — but the LAST song's teardown also fires song:stop after
|
||||
// song:ended, so only abandon while songs genuinely remain.
|
||||
if (!_ppGigRun) return;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
const active = q && typeof q.active === 'function' ? q.active() : false;
|
||||
if (!active) abandonGig();
|
||||
}
|
||||
|
||||
function openGenre(inst, genre) {
|
||||
fetch(`${API}/passports/open`, {
|
||||
method: 'POST',
|
||||
@@ -872,6 +1580,45 @@
|
||||
closeBook();
|
||||
return;
|
||||
}
|
||||
// Tuning pref pill on the gig poster
|
||||
const tuningPill = e.target.closest('[data-pp-tuning]');
|
||||
if (tuningPill) {
|
||||
const pref = tuningPill.dataset.ppTuning;
|
||||
if (pref === 'specific') {
|
||||
_openSpecificTuningPicker();
|
||||
} else {
|
||||
_ppGigTuningPref = pref;
|
||||
lsSet(PP_TUNING_PREF_KEY, pref);
|
||||
_ppGigTuningNames = []; // reset specific cache on pref change
|
||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// "Start song" interstitial button (mid-gig tuning pause)
|
||||
if (e.target.closest('[data-pp-gig-start-song]') && _ppGigTuningHold) {
|
||||
const release = _ppGigTuningHold;
|
||||
_ppGigTuningHold = null;
|
||||
renderGigStrip();
|
||||
release();
|
||||
return;
|
||||
}
|
||||
const gigBtn = e.target.closest('[data-pp-gig]');
|
||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
||||
if (e.target.closest('[data-pp-gig-reroll]')) {
|
||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||
return;
|
||||
}
|
||||
const posterBtn = e.target.closest('[data-pp-poster]');
|
||||
if (posterBtn && _ppGigProposal) {
|
||||
exportGigPoster(posterBtn.dataset.ppPoster, _ppGigProposal);
|
||||
return;
|
||||
}
|
||||
const cardBtn = e.target.closest('[data-pp-card]');
|
||||
if (cardBtn) {
|
||||
exportPassportCard(cardBtn.dataset.ppCard);
|
||||
return;
|
||||
}
|
||||
const dlBtn = e.target.closest('[data-career-download]');
|
||||
const delBtn = e.target.closest('[data-career-delete]');
|
||||
const playBtn = e.target.closest('[data-career-play]');
|
||||
@@ -912,16 +1659,32 @@
|
||||
}
|
||||
|
||||
function boot() {
|
||||
// Restore persisted tuning preference
|
||||
_ppGigTuningPref = lsGet(PP_TUNING_PREF_KEY) || 'any';
|
||||
const screen = document.getElementById('plugin-career');
|
||||
if (screen) {
|
||||
screen.addEventListener('click', onClick);
|
||||
screen.addEventListener('pointermove', onTiltMove);
|
||||
screen.addEventListener('pointerleave', onTiltLeave);
|
||||
// Specific-tuning select change: rebook with the chosen tuning
|
||||
screen.addEventListener('change', (e) => {
|
||||
const sel = e.target.closest('[data-pp-tuning-select]');
|
||||
if (!sel || !_ppGigProposal) return;
|
||||
const val = sel.value;
|
||||
if (!val) return;
|
||||
_ppGigTuningPref = 'specific:' + val;
|
||||
lsSet(PP_TUNING_PREF_KEY, _ppGigTuningPref);
|
||||
bookGig(_ppGigProposal.genre_key);
|
||||
});
|
||||
}
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
// New song stats can add stars → thresholds may cross mid-session.
|
||||
sm.on('stats:recorded', () => refresh());
|
||||
// Gig runner lifecycle (no-ops when no gig is live).
|
||||
sm.on('song:loading', onGigSongLoading);
|
||||
sm.on('song:ended', onGigSongEnded);
|
||||
sm.on('song:stop', onGigSongStop);
|
||||
// Virtuoso's progress emits are the drill-state relay trigger; the
|
||||
// payload is a thin delta, so the relay reads the full localStorage
|
||||
// snapshot instead (see relayDrillState).
|
||||
@@ -929,8 +1692,14 @@
|
||||
}
|
||||
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||
if (e.key !== 'Escape') return;
|
||||
const overlay = $('pp-overlay');
|
||||
if (_ppBook || (overlay && !overlay.classList.contains('hidden'))) closeBook();
|
||||
});
|
||||
// Core re-renders profile/dashboard shells (innerHTML wipe) and
|
||||
// announces the fresh mount points — same seam achievements uses.
|
||||
document.addEventListener('v3:profile-rendered', renderProfileWall);
|
||||
document.addEventListener('v3:dashboard-rendered', renderDashCard);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -938,7 +1707,21 @@
|
||||
// the badge-diff logic; nothing here touches the DOM.
|
||||
window.__careerPassportTest = {
|
||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||
fmtHours, ppFillFraction,
|
||||
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||
onGigSongEnded, onGigSongStop, onGigSongLoading,
|
||||
setGigRun(r) { _ppGigRun = r; },
|
||||
getGigRun() { return _ppGigRun; },
|
||||
setView(v) { _pp = v; },
|
||||
getTuningHold() { return _ppGigTuningHold; },
|
||||
setTuningHold(h) { _ppGigTuningHold = h; },
|
||||
getTuningPref() { return _ppGigTuningPref; },
|
||||
setTuningPref(p) { _ppGigTuningPref = p; },
|
||||
getLastTuning() { return _ppGigLastTuning; },
|
||||
setLastTuning(t) { _ppGigLastTuning = t; },
|
||||
getBookGen() { return _ppBookGen; },
|
||||
setBookGen(g) { _ppBookGen = g; },
|
||||
getProposal() { return _ppGigProposal; },
|
||||
bookGig, closeBook,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -150,3 +150,100 @@ test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
||||
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,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.
@@ -17,14 +17,22 @@
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": null
|
||||
"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": null
|
||||
"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",
|
||||
"type": "visualization",
|
||||
"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"] },
|
||||
"routes": "routes.py",
|
||||
"tour": "tour.json"
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"routes": "routes.py",
|
||||
"tour": "tour.json"
|
||||
}
|
||||
|
||||
+1061
-10985
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 };
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// h3d-carve-9: W-section (camera lerp) — effectiveVfov + camUpdate.
|
||||
// Cut 13 (S-section lookahead) extends this factory in place: same createCamera({…})
|
||||
// destructure grows with additional DI params and returned symbols.
|
||||
//
|
||||
// DI surface (per §2 of the cut-9 contract):
|
||||
// Constants (22) — BASE_VFOV, HORPLUS_*, CAM_LERP_BASE, CAM_H/DIST_BASE,
|
||||
// 6× CAM_FRAME_*, FOCUS_D, S_GAP, K, 3× FRET_ROW_FIT_*,
|
||||
// 4× CAM_TILT_*
|
||||
// Getters (11) — getCam, getTgtX/Dist, getAspectScale, getLeftyCached,
|
||||
// getNStr, getProbe, getTiltSmoothing, getPaneAspect/Uid,
|
||||
// getHighwayCanvas
|
||||
// Pairs (5) — getCurX/setCurX, getCurDist/setCurDist,
|
||||
// getCurLookY/setCurLookY, getTgtLookY/setTgtLookY,
|
||||
// getFretRowFitBoost/setFretRowFitBoost
|
||||
// Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor,
|
||||
// aspectRegisterPane
|
||||
|
||||
import { computeBPM, lowerBoundT, camBaseDistU, camLowFretPullbackU } from './geometry.js'; // h3d-carve-1,13
|
||||
import { _ssActive } from './utils.js'; // h3d-carve-3
|
||||
|
||||
export function createCamera({
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
|
||||
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
|
||||
CAM_FRAME_DIST_NEAR, CAM_FRAME_DIST_FAR,
|
||||
CAM_FRAME_H_NEAR, CAM_FRAME_H_FAR,
|
||||
CAM_FRAME_D_NEAR, CAM_FRAME_D_FAR,
|
||||
FOCUS_D, S_GAP, K,
|
||||
FRET_ROW_FIT_NDC_MIN, FRET_ROW_FIT_DEADBAND, FRET_ROW_FIT_BOOST_MAX,
|
||||
CAM_TILT_BAND_T, CAM_TILT_BAND_C, CAM_TILT_STR_T, CAM_TILT_STR_C,
|
||||
// ── Live-accessor getters ───────────────────────────────────────────────
|
||||
getCam,
|
||||
getTgtX, getTgtDist, getAspectScale, getLeftyCached,
|
||||
getNStr, getProbe, getTiltSmoothing, getPaneAspect, getPaneUid,
|
||||
getHighwayCanvas,
|
||||
// ── Getter+setter pairs (write-backs) ──────────────────────────────────
|
||||
getCurX, setCurX,
|
||||
getCurDist, setCurDist,
|
||||
getCurLookY, setCurLookY,
|
||||
getTgtLookY, setTgtLookY,
|
||||
getFretRowFitBoost, setFretRowFitBoost,
|
||||
// ── Function refs ───────────────────────────────────────────────────────
|
||||
sY, freeCamFor, aspectPaneKey, resolveTuneFor, aspectRegisterPane,
|
||||
// h3d-carve-13: S-section lookahead — +4 const shorthand, +1 getter, +4 fn refs
|
||||
NFRETS, CAM_LOOKAHEAD_MEASURES, CAM_LOOKAHEAD_SEC, CAM_FRET_EDGE_BLEND,
|
||||
getMeasureStarts,
|
||||
validString, getChartAnchorAt, xFretMid, xFret,
|
||||
}) {
|
||||
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
|
||||
// camera should use for the given pane aspect. With the bridge off (or
|
||||
// absent), or at/under the start aspect, it returns the base vertical
|
||||
// fov unchanged — an exact no-op, so normal panes render identically to
|
||||
// before. Past the start aspect it lowers the vertical fov to keep the
|
||||
// horizontal cone ~constant, so the neck fills an ultra-wide pane
|
||||
// instead of collapsing into a central sliver. Pure + finite-guarded.
|
||||
function effectiveVfov(aspect, tune) {
|
||||
// VERBATIM MOVE. 0 beyond-subst: BASE_VFOV / HORPLUS_* / HORPLUS_MIN_VFOV
|
||||
// are plain DI params in the factory destructure — no rewires needed.
|
||||
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
|
||||
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
|
||||
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
|
||||
? tune.startAspect : HORPLUS_START_ASPECT;
|
||||
if (aspect <= start) return base;
|
||||
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
|
||||
const DEG = Math.PI / 180;
|
||||
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
|
||||
// cone the base vertical fov produces at the start aspect.
|
||||
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
|
||||
? tune.hfovDeg * DEG
|
||||
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
|
||||
// Vertical fov that reproduces that horizontal cone at this aspect.
|
||||
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
|
||||
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
|
||||
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
|
||||
if (!Number.isFinite(vfov)) return base;
|
||||
return Math.max(floor, Math.min(base, vfov));
|
||||
}
|
||||
|
||||
/* ── Camera smooth lerp ──────────────────────────────────────────── */
|
||||
function camUpdate(bundle) {
|
||||
// VERBATIM MOVE. DI rewires — 25 beyond-subst:
|
||||
// Local aliases intro (10): cam, paneAspect, curX, curDist, curLookY,
|
||||
// tgtLookY, _fretRowFitBoost, nStr, _probe, tiltSmoothing
|
||||
// Fn-ref renames (4): _aspectPaneKey→aspectPaneKey,
|
||||
// _aspectRegisterPane→aspectRegisterPane,
|
||||
// _resolveTuneFor→resolveTuneFor, _freeCamFor→freeCamFor
|
||||
// Direct getter calls (6): getPaneUid, getTgtX, getTgtDist,
|
||||
// getAspectScale, getLeftyCached, getHighwayCanvas
|
||||
// Write-back setter calls (5): setCurX, setCurDist, setCurLookY,
|
||||
// setTgtLookY, setFretRowFitBoost
|
||||
const bpm = computeBPM(bundle.beats, bundle.currentTime);
|
||||
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
|
||||
|
||||
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
|
||||
// Driven by window.__h3dAspectTune (default off → exact no-op).
|
||||
// _resolveTuneFor(paneKey) returns the shared base with THIS pane's
|
||||
// overrides (if any) laid on top, so a single split pane can be framed
|
||||
// independently. The base is seeded from defaults + localStorage on
|
||||
// first read, so a persisted tuning session applies on load without
|
||||
// opening the panel. Every field is finite-coerced. When disabled (or
|
||||
// splitOnly and not in a split) the tune is treated as null, so
|
||||
// effectiveVfov returns the base vertical fov and cam.fov is restored
|
||||
// to it. The fov write is guarded on an actual change so a steady pane
|
||||
// costs nothing.
|
||||
const cam = getCam(); // DI rewire: live-accessor
|
||||
const paneAspect = getPaneAspect(); // DI rewire: live-accessor (replaces _paneAspect)
|
||||
const _paneKey = aspectPaneKey( // DI rewire: fn-ref rename
|
||||
bundle && bundle.songInfo && bundle.songInfo.arrangement, getPaneUid()); // DI rewire: getPaneUid
|
||||
// Only feed the Target-picker registry while the tuner is open (same
|
||||
// gate as the readout). Closed → nothing is registered, so the registry
|
||||
// can't grow for users who never open the panel; the key is still
|
||||
// resolved below so any saved overrides keep applying.
|
||||
if (window.__h3dAspectPanelOpen) aspectRegisterPane(_paneKey); // DI rewire: fn-ref rename
|
||||
const _aspTune = resolveTuneFor(_paneKey); // DI rewire: fn-ref rename
|
||||
const _aspActive = !!(_aspTune && _aspTune.enabled
|
||||
&& !(_aspTune.splitOnly && !_ssActive()));
|
||||
const _tune = _aspActive ? _aspTune : null;
|
||||
const _vfov = effectiveVfov(paneAspect, _tune); // DI rewire: paneAspect
|
||||
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
|
||||
cam.fov = _vfov;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
// Publish a per-pane live readout for the tuner panel (only while it's
|
||||
// open, so the steady path stays allocation-free). Keyed by pane so
|
||||
// the panel can show the reading for whichever target is selected.
|
||||
if (window.__h3dAspectPanelOpen) {
|
||||
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
|
||||
const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
|
||||
_slot.aspect = paneAspect; _slot.vfov = _vfov; // DI rewire: paneAspect
|
||||
_ro.__last = _paneKey;
|
||||
}
|
||||
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
|
||||
// wide-pane look if fov alone isn't enough. Gated to wide panes and
|
||||
// suppressed while the Camera Director owns the view (it wins).
|
||||
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
|
||||
? _tune.startAspect : HORPLUS_START_ASPECT;
|
||||
// Resolve the Camera Director bridge once (per-panel under splitscreen,
|
||||
// else global). Used both for the wide-pane gate and the transforms below.
|
||||
const _freeCam = freeCamFor(getHighwayCanvas()); // DI rewire: fn-ref rename + getter
|
||||
const _dirActive = !!(_freeCam && _freeCam.enabled);
|
||||
const _wide = !!(_tune && paneAspect > _startAspect) && !_dirActive; // DI rewire: paneAspect
|
||||
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
|
||||
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
|
||||
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
|
||||
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
|
||||
? _tune.lookDepthMul : 1;
|
||||
|
||||
// DI rewire: lerped state — read per-call into locals, mutate locally,
|
||||
// write back via setters so screen.js closure vars stay in sync.
|
||||
// (Never cached at factory init — buildBoard may reset these between frames.)
|
||||
let curX = getCurX(); // DI rewire: local alias
|
||||
curX += (getTgtX() - curX) * lerp; // DI rewire: getTgtX()
|
||||
setCurX(curX); // write-back
|
||||
let _fretRowFitBoost = getFretRowFitBoost(); // DI rewire: local alias
|
||||
let curDist = getCurDist(); // DI rewire: local alias
|
||||
// The fret-row fit guard (end of camUpdate) may dolly the camera back
|
||||
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
|
||||
curDist += (getTgtDist() * _fretRowFitBoost - curDist) * lerp; // DI rewire: getTgtDist()
|
||||
setCurDist(curDist); // write-back
|
||||
const dist = curDist * getAspectScale(); // DI rewire: getAspectScale()
|
||||
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
|
||||
|
||||
// Zoom-interpolated framing multipliers: tight (NEAR) -> lower/closer;
|
||||
// wide (FAR, fret 1<->20) -> higher/pulled back.
|
||||
const _zt = Math.max(0, Math.min(1,
|
||||
(dist - CAM_FRAME_DIST_NEAR) / (CAM_FRAME_DIST_FAR - CAM_FRAME_DIST_NEAR)));
|
||||
const _hMul = CAM_FRAME_H_NEAR + (CAM_FRAME_H_FAR - CAM_FRAME_H_NEAR) * _zt;
|
||||
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
|
||||
const shoulderOffset = (getLeftyCached() ? -1 : 1) * 10 * K; // DI rewire: getLeftyCached()
|
||||
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
|
||||
// Optional wide-pane pose nudges (default identity → no-op).
|
||||
if (_poseHMul !== 1) _camY *= _poseHMul;
|
||||
if (_poseDMul !== 1) _camZ *= _poseDMul;
|
||||
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
|
||||
// Driven by the Camera Director plugin via the camera bridge:
|
||||
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
|
||||
// panel's own camera), falling back to the global window.__h3dCamCtl.
|
||||
// Layered ON TOP of the auto-framing so note tracking still works.
|
||||
// The bridge is read once into _freeCam and reused for both the
|
||||
// position and the look-at transforms; every field is coerced to a
|
||||
// finite number before use so a malformed object can never feed NaN
|
||||
// into cam.position / cam.lookAt.
|
||||
// _freeCam resolved above via freeCamFor(getHighwayCanvas()): the
|
||||
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
|
||||
let curLookY = getCurLookY(); // DI rewire: local alias (read before freeCam block)
|
||||
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
||||
if (_freeCam && _freeCam.enabled) {
|
||||
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
|
||||
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
|
||||
const _yaw = Number.isFinite(_freeCam.yaw) ? _freeCam.yaw : 0;
|
||||
const _tx = curX, _ty = curLookY, _tz = _lookAtZ; // look target
|
||||
let _vx = _camX - _tx, _vy = _camY - _ty, _vz = _camZ - _tz;
|
||||
_vx *= _distMul; _vy *= _distMul; _vz *= _distMul; // zoom (dolly)
|
||||
_vy *= _heightMul; // height
|
||||
const _cy = Math.cos(_yaw), _sy = Math.sin(_yaw); // orbit around Y
|
||||
const _rx = _vx * _cy - _vz * _sy, _rz = _vx * _sy + _vz * _cy;
|
||||
_camX = _tx + _rx; _camY = _ty + _vy; _camZ = _tz + _rz;
|
||||
}
|
||||
cam.position.set(_camX, _camY, _camZ);
|
||||
|
||||
// Self-correcting look-at Y: project the fretboard's near-edge centre
|
||||
// to NDC space. If it drifts toward the frame edge, nudge tgtLookY
|
||||
// toward the fretboard centre so the camera tilts to re-frame it.
|
||||
// This lets the camera adapt to any panel aspect ratio automatically.
|
||||
const nStr = getNStr(); // DI rewire: local alias
|
||||
const _probe = getProbe(); // DI rewire: local alias
|
||||
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
|
||||
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
|
||||
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
|
||||
cam.updateMatrixWorld();
|
||||
_probe.project(cam); // _probe.y → NDC in [-1, 1]
|
||||
|
||||
// Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35).
|
||||
// The deadband width and correction strength are both blended
|
||||
// between Twitchy and Calm bounds by the user's tiltSmoothing
|
||||
// setting — twitchy = re-frame aggressively (narrow band, strong
|
||||
// nudge); calm = let small drift ride (wide band, weak nudge).
|
||||
const DESIRED_NDC_Y = -0.35;
|
||||
const tiltSmoothing = getTiltSmoothing(); // DI rewire: local alias
|
||||
const tiltBand = CAM_TILT_BAND_T + (CAM_TILT_BAND_C - CAM_TILT_BAND_T) * tiltSmoothing;
|
||||
const tiltStr = CAM_TILT_STR_T + (CAM_TILT_STR_C - CAM_TILT_STR_T) * tiltSmoothing;
|
||||
let tgtLookY = getTgtLookY(); // DI rewire: local alias
|
||||
if (_probe.y < DESIRED_NDC_Y - tiltBand || _probe.y > DESIRED_NDC_Y + tiltBand) {
|
||||
// _probe.y too low → fretboard near bottom → tgtLookY decreases → camera tilts down → fretboard rises
|
||||
// _probe.y too high → fretboard near top → tgtLookY increases → camera tilts up → fretboard drops
|
||||
const correction = (DESIRED_NDC_Y - _probe.y) * fretMidY * tiltStr;
|
||||
tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction));
|
||||
}
|
||||
setTgtLookY(tgtLookY); // write-back
|
||||
curLookY += (tgtLookY - curLookY) * lerp;
|
||||
setCurLookY(curLookY); // write-back
|
||||
|
||||
// Final look-at with the corrected Y (overrides the tentative one above).
|
||||
// User tilt (pitch) + pan offsets layer on top when the free-cam is
|
||||
// enabled; each is coerced to a finite number to avoid a NaN look-at.
|
||||
if (_freeCam && _freeCam.enabled) {
|
||||
const _panX = Number.isFinite(_freeCam.panX) ? _freeCam.panX : 0;
|
||||
const _panY = Number.isFinite(_freeCam.panY) ? _freeCam.panY : 0;
|
||||
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
|
||||
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
|
||||
} else {
|
||||
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
|
||||
}
|
||||
|
||||
// ── Fret-row fit guard ────────────────────────────────────────────
|
||||
// Project the fret-number-row band (just below the lowest string, at
|
||||
// the play line) with the final camera. If it sits below the safe
|
||||
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
|
||||
// curDist lerp target next frame) until it clears; relax lazily once
|
||||
// there's comfortable headroom. Asymmetric + deadbanded so it
|
||||
// converges without hunting, and capped so the zoom can't pop. It
|
||||
// cooperates with the tilt loop above rather than fighting it: pulling
|
||||
// back shrinks the scene, the tilt loop keeps the board centre anchored
|
||||
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
|
||||
// while the free-cam (Camera Director) owns the view.
|
||||
if (_freeCam && _freeCam.enabled) {
|
||||
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
|
||||
} else {
|
||||
cam.updateMatrixWorld();
|
||||
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
|
||||
_probe.set(curX, _rowY, 0.5 * K);
|
||||
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
|
||||
const _rowNdcY = _probe.y;
|
||||
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
|
||||
// Row below the safe line → pull back promptly, proportional to
|
||||
// the deficit so it converges in a few frames without overshoot.
|
||||
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
|
||||
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
|
||||
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
|
||||
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
|
||||
&& _fretRowFitBoost > 1) {
|
||||
// Comfortable headroom → relax the dolly back toward normal, lazily.
|
||||
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
|
||||
}
|
||||
}
|
||||
setFretRowFitBoost(_fretRowFitBoost); // write-back
|
||||
}
|
||||
|
||||
/* ── h3d-carve-13: S-section lookahead helpers ───────────────────── */
|
||||
// VERBATIM MOVE from screen.js Region A (original lines 6630-6719).
|
||||
// 1 beyond-subst: _measureStarts → getMeasureStarts() (live getter).
|
||||
// lookaheadEndTime: factory-private (no external callers).
|
||||
function lookaheadEndTime(now) {
|
||||
const ms = getMeasureStarts(); // h3d-carve-13: _measureStarts → getMeasureStarts()
|
||||
if (!ms || ms.length === 0) return now + CAM_LOOKAHEAD_SEC;
|
||||
// Binary search: lo = first index with ms[lo] > now.
|
||||
let lo = 0, hi = ms.length;
|
||||
while (lo < hi) { const mid = (lo + hi) >> 1; if (ms[mid] <= now) lo = mid + 1; else hi = mid; }
|
||||
const curIdx = lo - 1; // current measure (-1 if before the first)
|
||||
const targetIdx = curIdx + CAM_LOOKAHEAD_MEASURES;
|
||||
if (targetIdx >= 0 && targetIdx < ms.length) return ms[targetIdx];
|
||||
// Past the last measure: extrapolate using the average measure duration.
|
||||
if (ms.length >= 2) {
|
||||
const avg = (ms[ms.length - 1] - ms[0]) / (ms.length - 1);
|
||||
if (avg > 0) return ms[ms.length - 1] + (targetIdx - (ms.length - 1)) * avg;
|
||||
}
|
||||
return now + CAM_LOOKAHEAD_SEC;
|
||||
}
|
||||
|
||||
// Earliest future chart time whose lookahead end reaches eventTime.
|
||||
// lookaheadEndTime() is monotonic but measure-stepped, so a small
|
||||
// bounded binary search works for both measure grids and the seconds
|
||||
// fallback without duplicating/inverting its edge-case logic.
|
||||
function lookaheadBootstrapTime(now, eventTime) {
|
||||
if (!(eventTime > now) || lookaheadEndTime(now) >= eventTime) return now;
|
||||
let lo = now;
|
||||
let hi = eventTime;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const mid = (lo + hi) * 0.5;
|
||||
if (lookaheadEndTime(mid) >= eventTime) hi = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
return hi;
|
||||
}
|
||||
|
||||
function lookaheadComputeFretBounds(now, anchors, notes, chords) {
|
||||
const tEnd = lookaheadEndTime(now);
|
||||
let minF = 99;
|
||||
let maxF = 0;
|
||||
let any = false;
|
||||
if (anchors && anchors.length) {
|
||||
for (let tt = now; tt <= tEnd + 1e-9; tt += 0.125) {
|
||||
const a = getChartAnchorAt(anchors, tt);
|
||||
if (!a) continue;
|
||||
let fStart = Math.round(Number(a.fret));
|
||||
if (!Number.isFinite(fStart) || fStart < 1) fStart = 1;
|
||||
let w = Number(a.width);
|
||||
if (!Number.isFinite(w)) w = 4;
|
||||
w = Math.max(1, Math.round(w));
|
||||
const fHi = Math.min(NFRETS, fStart + w - 1);
|
||||
minF = Math.min(minF, fStart);
|
||||
maxF = Math.max(maxF, fHi);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
const consider = f => {
|
||||
if (!(f > 0)) return;
|
||||
minF = Math.min(minF, f);
|
||||
maxF = Math.max(maxF, f);
|
||||
any = true;
|
||||
};
|
||||
if (notes) {
|
||||
let i = lowerBoundT(notes, now);
|
||||
for (; i < notes.length; i++) {
|
||||
const n = notes[i];
|
||||
if (n.t > tEnd) break;
|
||||
if (!validString(n.s)) continue;
|
||||
consider(n.f);
|
||||
}
|
||||
}
|
||||
if (chords) {
|
||||
let i = lowerBoundT(chords, now);
|
||||
for (; i < chords.length; i++) {
|
||||
const ch = chords[i];
|
||||
if (ch.t > tEnd) break;
|
||||
if (!ch.notes) continue;
|
||||
for (const cn of ch.notes) {
|
||||
if (!validString(cn.s)) continue;
|
||||
consider(cn.f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!any || minF > maxF) return null;
|
||||
return { minF, maxF };
|
||||
}
|
||||
|
||||
function lookaheadTargetWorldX(minF, maxF) {
|
||||
const wb = CAM_FRET_EDGE_BLEND;
|
||||
const middle = (xFretMid(minF) + xFretMid(maxF)) * 0.5;
|
||||
const weighted = 0.6 * xFret(0) + 0.4 * xFret(NFRETS);
|
||||
return middle * (1 - wb) + weighted * wb;
|
||||
}
|
||||
|
||||
return { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// h3d-carve-8: Q-section partial — lighting/FX utilities.
|
||||
// buildBoard stays in screen.js (deferred to plan §3 row 16, P-section cut)
|
||||
// because its write-back surface spans 9 factory-scope vars that P/U/Y also
|
||||
// own; premature extraction would require a ~50-param DI. See plans/highway3d-carve.md.
|
||||
//
|
||||
// DI surface (per-call live-accessors — never cache at factory init):
|
||||
// BG_DEFAULTS, K — plain IIFE-scope constants
|
||||
// getT — live-accessor (Three.js, lazy-loaded)
|
||||
// getAmbLight/getDirLight — live-accessors (null until initScene)
|
||||
// getCinematic/getTimingFx — live-accessors (toggle flags)
|
||||
// getSparkPts/setSparkPts — live-accessor + setter (Points object, set by buildBoard)
|
||||
// getSparkN/getSparkPos/… — live-accessors for particle buffers (element-mutated in place)
|
||||
// getComposer/setComposer — getter+setter (lazy-assigned inside _bloomEnsure)
|
||||
// getBloomLoad/setBloomLoad — getter+setter (Promise, assigned inside _bloomEnsure)
|
||||
// getBloomPass/setBloomPass — getter+setter (pass object, assigned inside _bloomEnsure)
|
||||
// getBloomW/setBloomW/H — getter+setter (dimensions, assigned inside _bloomEnsure)
|
||||
// getRen/getScene/getCam/getHighwayCanvas — live-accessors (null until initScene)
|
||||
// canvasSize — factory-scope function ref (stable)
|
||||
|
||||
export function createFx({
|
||||
BG_DEFAULTS, K,
|
||||
getT,
|
||||
getAmbLight, getDirLight, getCinematic,
|
||||
getTimingFx,
|
||||
getSparkPts, setSparkPts, getSparkN,
|
||||
getSparkPos, setSparkPos, getSparkVel, setSparkVel,
|
||||
getSparkCol, setSparkCol, getSparkLife, setSparkLife,
|
||||
getComposer, setComposer,
|
||||
getBloomLoad, setBloomLoad,
|
||||
getBloomPass, setBloomPass,
|
||||
getBloomW, setBloomW, getBloomH, setBloomH,
|
||||
getRen, getScene, getCam, getHighwayCanvas,
|
||||
canvasSize,
|
||||
}) {
|
||||
function _h3dHexOrDefault(hexStr, defHex) {
|
||||
// VERBATIM MOVE. BG_DEFAULTS from plain DI param.
|
||||
const d = defHex || BG_DEFAULTS.nutColor;
|
||||
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
|
||||
? hexStr.trim().toLowerCase()
|
||||
: d;
|
||||
return parseInt(s.slice(1), 16);
|
||||
}
|
||||
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
|
||||
// surround to pop against; strengthen the key light for modelling.
|
||||
// Toggle via the 'cinematic' setting so it's directly comparable.
|
||||
function _applyCinematic() {
|
||||
// VERBATIM MOVE. DI rewire: ambLight/dirLight/_cinematic from live-accessors.
|
||||
const ambLight = getAmbLight(), dirLight = getDirLight(), _cinematic = getCinematic();
|
||||
if (!ambLight || !dirLight) return;
|
||||
ambLight.intensity = _cinematic ? 0.45 : 0.85;
|
||||
dirLight.intensity = _cinematic ? 1.15 : 0.8;
|
||||
}
|
||||
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
|
||||
// late amber. Falls back to green when timing is unknown (pure-provider path).
|
||||
function _timingHex(ts) {
|
||||
// VERBATIM MOVE. DI rewire: _timingFx from getTimingFx().
|
||||
if (!getTimingFx() || !ts || ts === 'OK') return 0x22ff88;
|
||||
if (ts === 'EARLY') return 0x35d6ff;
|
||||
if (ts === 'LATE') return 0xffb84d;
|
||||
return 0x22ff88;
|
||||
}
|
||||
function _sparkBurst(x, y, z, hex, count) {
|
||||
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call,
|
||||
// not cached at factory init — buildBoard may reassign the refs).
|
||||
const _sparkPts = getSparkPts();
|
||||
if (!_sparkPts || count <= 0) return;
|
||||
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
|
||||
const _SPARK_N = getSparkN();
|
||||
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
|
||||
let made = 0;
|
||||
for (let i = 0; i < _SPARK_N && made < count; i++) {
|
||||
if (_sparkLife[i] > 0) continue;
|
||||
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
|
||||
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
|
||||
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
|
||||
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
|
||||
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
|
||||
}
|
||||
}
|
||||
function _sparkUpdate(dt) {
|
||||
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call).
|
||||
const _sparkPts = getSparkPts();
|
||||
if (!_sparkPts) return;
|
||||
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
|
||||
const _SPARK_N = getSparkN();
|
||||
const grav = 55 * K; let any = false;
|
||||
for (let i = 0; i < _SPARK_N; i++) {
|
||||
if (_sparkLife[i] <= 0) continue;
|
||||
const j = i * 3;
|
||||
_sparkLife[i] -= dt;
|
||||
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
|
||||
any = true;
|
||||
_sparkVel[j + 1] -= grav * dt;
|
||||
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
|
||||
const fade = 1 - Math.min(1, dt * 3.2);
|
||||
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
|
||||
}
|
||||
_sparkPts.geometry.attributes.position.needsUpdate = true;
|
||||
_sparkPts.geometry.attributes.color.needsUpdate = true;
|
||||
_sparkPts.visible = any;
|
||||
}
|
||||
// #4 Bloom — .then() body extracted for test harness reach (Toby r1 F1).
|
||||
// Called with [EC, RP, UB, OP] when all four postprocessing modules resolve.
|
||||
// DI rewire: T/ren/scene/cam/highwayCanvas from live-accessors (per-call).
|
||||
function _applyBloom([EC, RP, UB, OP]) {
|
||||
// VERBATIM MOVE of the .then() handler body from _bloomEnsure.
|
||||
// DI rewire: T/ren/scene/cam/highwayCanvas read from live-accessors.
|
||||
try {
|
||||
const T = getT(), highwayCanvas = getHighwayCanvas();
|
||||
const ren = getRen(), scene = getScene(), cam = getCam();
|
||||
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
|
||||
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
|
||||
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
|
||||
// survives the bloom path — EffectComposer's default target has no
|
||||
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
|
||||
// DPR1 displays that have no supersampling cushion).
|
||||
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
|
||||
const comp = new EC.EffectComposer(ren, _bloomRT);
|
||||
comp.addPass(new RP.RenderPass(scene, cam));
|
||||
const bp = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
|
||||
setBloomPass(bp);
|
||||
comp.addPass(bp);
|
||||
comp.addPass(new OP.OutputPass());
|
||||
comp.setSize(w, h);
|
||||
setBloomW(w); setBloomH(h); setComposer(comp);
|
||||
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); setComposer(null); }
|
||||
}
|
||||
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
|
||||
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
|
||||
// the composer once ready, or null (caller falls back to a direct render).
|
||||
function _bloomEnsure() {
|
||||
// VERBATIM MOVE. DI rewire: all factory-scope vars via get/set accessors.
|
||||
// _composer, _bloomLoad, _bloomPass, _bloomW, _bloomH are REASSIGNED via
|
||||
// _applyBloom — setters required; must NOT silently become locals.
|
||||
if (getComposer()) return getComposer();
|
||||
const ren = getRen(), scene = getScene(), cam = getCam();
|
||||
if (getBloomLoad() || !ren || !scene || !cam) return null;
|
||||
const A = '/static/vendor/three/addons/';
|
||||
setBloomLoad(Promise.all([
|
||||
import(A + 'postprocessing/EffectComposer.js'),
|
||||
import(A + 'postprocessing/RenderPass.js'),
|
||||
import(A + 'postprocessing/UnrealBloomPass.js'),
|
||||
import(A + 'postprocessing/OutputPass.js'),
|
||||
]).then(_applyBloom).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)));
|
||||
return null;
|
||||
}
|
||||
|
||||
return { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _applyBloom, _bloomEnsure };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Pure geometry helpers — h3d-carve-1.
|
||||
*
|
||||
* All exports are stateless; they depend only on the compile-time constants
|
||||
* below (which mirror their factory-scope counterparts in screen.js verbatim
|
||||
* and never vary at runtime). No DOM, no Three.js imports, no side-effects.
|
||||
*
|
||||
* screen.js keeps a 1-arg delegator:
|
||||
* const fretX = f => geoFretX(f, _h3dFretUniform);
|
||||
* so no call site in screen.js changes.
|
||||
*/
|
||||
|
||||
// ── Compile-time constants (mirror screen.js; never vary at runtime) ─────────
|
||||
|
||||
const SCALE = 2.25;
|
||||
const K = SCALE / 300;
|
||||
// Horizontal stretch factor for fret X positions.
|
||||
const FRET_SCALE = SCALE * 1.1;
|
||||
const NFRETS = 24;
|
||||
/**
|
||||
* Pure 12-semitone spacing compresses toward the bridge; multiply each
|
||||
* segment above this fret by the factor so high positions stay
|
||||
* slightly more playable/readable in 3D.
|
||||
*/
|
||||
const FRET_SPACING_STRETCH_ABOVE12 = 1.1;
|
||||
const FRET_SPACING_ANCHOR_F = 12;
|
||||
/** Note travel speed. */
|
||||
const TS = 230 * K;
|
||||
|
||||
// ── Fret X ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Logarithmic spacing — mirrors real guitar fret geometry (12th root of 2).
|
||||
const _fretXLog = f => {
|
||||
if (f <= 0) return 0;
|
||||
const raw = FRET_SCALE - FRET_SCALE / Math.pow(2, f / 12);
|
||||
if (f <= FRET_SPACING_ANCHOR_F) return raw;
|
||||
const rawAnchor = FRET_SCALE - FRET_SCALE / Math.pow(2, FRET_SPACING_ANCHOR_F / 12);
|
||||
return rawAnchor + (raw - rawAnchor) * FRET_SPACING_STRETCH_ABOVE12;
|
||||
};
|
||||
// Uniform spacing — same column width per fret (chart-format style).
|
||||
// Total board width equals the logarithmic NFRETS position for consistency.
|
||||
const _fretXUniStep = _fretXLog(NFRETS) / NFRETS;
|
||||
const _fretXUni = f => f <= 0 ? 0 : f * _fretXUniStep;
|
||||
|
||||
/**
|
||||
* World-space X position for fret `f`.
|
||||
* @param {number} f fret number
|
||||
* @param {boolean} uniform true → uniform (chart-format) spacing; false → logarithmic
|
||||
*/
|
||||
export const geoFretX = (f, uniform) => uniform ? _fretXUni(f) : _fretXLog(f);
|
||||
|
||||
// ── Time → Z ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert a time delta (seconds) to a world-space Z offset (notes travel toward −Z). */
|
||||
export const dZ = dt => -dt * TS;
|
||||
|
||||
// ── Slide trail ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pitched slide uses `sl`, unpitched uses `slu` (slide-to vs unpitched slide fields).
|
||||
* Prefer `sl` when both are present — matches RS wire.
|
||||
* @returns {{ endFret: number, unpitched: boolean } | null}
|
||||
*/
|
||||
export function slideTrailEnd(n) {
|
||||
const sl = n.sl;
|
||||
const slu = n.slu;
|
||||
if (Number.isFinite(sl) && sl >= 0) {
|
||||
return { endFret: sl | 0, unpitched: false };
|
||||
}
|
||||
if (Number.isFinite(slu) && slu >= 0) {
|
||||
return { endFret: slu | 0, unpitched: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Camera distance building blocks ──────────────────────────────────────────
|
||||
|
||||
// Camera tgtDist building blocks. Both the dynamic (camera-follow)
|
||||
// and locked (frets 1-12) branches compose tgtDist from these, so
|
||||
// any future tuning of the base zoom curve or low-fret pullback
|
||||
// lands in both branches without drift.
|
||||
// span — camDistMax - camDistMin in fret-span units
|
||||
// minFret — lowest fretted note in the camera window (or 1 for
|
||||
// the locked branch, which assumes nut chords)
|
||||
export const camBaseDistU = span => 65 + Math.max(span, 4) * 3;
|
||||
export const camLowFretPullbackU = minFret => Math.max(0, 5 - minFret) * 4;
|
||||
|
||||
// ── BPM estimation ────────────────────────────────────────────────────────────
|
||||
|
||||
export function computeBPM(beats, t) {
|
||||
if (!beats || beats.length < 2) return 120;
|
||||
let lo = 0, hi = beats.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (beats[mid].time < t) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
let closest = lo;
|
||||
if (lo === beats.length) closest = beats.length - 1;
|
||||
else if (lo > 0 && Math.abs(beats[lo - 1].time - t) < Math.abs(beats[lo].time - t)) closest = lo - 1;
|
||||
const start = Math.max(0, closest - 2);
|
||||
const end = Math.min(beats.length - 1, closest + 2);
|
||||
let sum = 0, count = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
const dt = beats[i + 1].time - beats[i].time;
|
||||
if (dt > 0) { sum += dt; count++; }
|
||||
}
|
||||
return count > 0 && sum > 0 ? 60 / (sum / count) : 120;
|
||||
}
|
||||
|
||||
// ── Render-order layer stack — h3d-carve-1b ───────────────────────────────────
|
||||
|
||||
export const RENDER_ORDER_LAYER_STACK = Object.freeze([
|
||||
'CHORD_FILL',
|
||||
'CHORD_STRUM_FILL',
|
||||
'CHORD_STRUM_LINE',
|
||||
'SUSTAIN_TRAIL',
|
||||
'CHORD_FRAME',
|
||||
'CHORD_EDGE_GLOW',
|
||||
'CONNECTOR_LINE',
|
||||
'FRET_COLUMN',
|
||||
'ARP_CONNECTOR_LINE',
|
||||
'NOTE_OUTLINE',
|
||||
'NOTE_CORE',
|
||||
'TECHNIQUE_MARKER',
|
||||
'BOARD_STRING',
|
||||
'BOARD_FRET_WIRE',
|
||||
'NOTE_FRET_LABEL',
|
||||
'ARP_NOTE_FRET_LABEL',
|
||||
'CHORD_FRET_LABEL',
|
||||
]);
|
||||
export const RENDER_ORDER_LAYER_INDEX = Object.freeze(RENDER_ORDER_LAYER_STACK.reduce(
|
||||
(indexByLayer, layerName, layerIndex) => {
|
||||
indexByLayer[layerName] = layerIndex;
|
||||
return indexByLayer;
|
||||
},
|
||||
Object.create(null)
|
||||
));
|
||||
|
||||
export const RENDER_ORDER_AT_Z_ZERO = 700;
|
||||
export const RENDER_ORDER_FAR_CLAMP = 50;
|
||||
|
||||
/**
|
||||
* Computes renderOrder from world depth plus a named layer.
|
||||
* Closer objects receive larger values and paint over farther objects; the
|
||||
* layer stack breaks ties at the same depth, keeping labels above note gems.
|
||||
*
|
||||
* The layer index is added as a sub-unit fraction (< 1) so the integer
|
||||
* depth bucket STRICTLY dominates: a farther object can never outrank a
|
||||
* nearer one merely because it sits on a higher layer. Adding the raw index
|
||||
* (0..N-1) directly would let the ~N-wide layer span leak across depth
|
||||
* buckets and re-introduce far-over-near bleed for notes within ~N draw
|
||||
* units of each other. Fraction granularity (1/N ≈ 0.06) stays well above
|
||||
* the 0.0001 intra-element sub-increments used at some call sites.
|
||||
*/
|
||||
export function renderOrderForLayerAtZ(worldZ, layerName) {
|
||||
const layerIndex = RENDER_ORDER_LAYER_INDEX[layerName];
|
||||
if (layerIndex === undefined) throw new Error(`Unknown 3D highway depth layer: ${layerName}`);
|
||||
const depthRenderOrder = Math.max(
|
||||
RENDER_ORDER_FAR_CLAMP,
|
||||
Math.round(RENDER_ORDER_AT_Z_ZERO + worldZ / K)
|
||||
);
|
||||
return depthRenderOrder + layerIndex / RENDER_ORDER_LAYER_STACK.length;
|
||||
}
|
||||
|
||||
// ── Note key and binary search — h3d-carve-1b ─────────────────────────────────
|
||||
|
||||
// Fast integer key for (t, s) pairs — avoids per-frame string allocation in
|
||||
// hot-path Set lookups. Encodes chart time in 0.1 ms steps (sufficient for
|
||||
// chart-format note precision) combined with the string index.
|
||||
// t range 0–600 s → 0–6,000,000; * 10 + s(0–7) = max 60,000,007 < 2^53 ✓.
|
||||
// The |0 truncates to int32 but the outer multiply stays in float64, so the
|
||||
// key is always a safe JS integer for songs ≤ 214,748 s (well above any song).
|
||||
export function _noteKey(t, s) { return ((t * 10000 + 0.5) | 0) * 10 + s; }
|
||||
|
||||
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
|
||||
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
|
||||
// Byte-identical to core's bundle.lowerBoundT — kept as a local because this
|
||||
// plugin must run on downlevel hosts whose bundles don't carry the helper
|
||||
// (it's called from ~30 sites incl. top-level helpers that don't receive a
|
||||
// bundle). New code that already holds a bundle should prefer
|
||||
// bundle.lowerBoundT / bundle.lowerBoundTime.
|
||||
export function lowerBoundT(arr, t) {
|
||||
let lo = 0, hi = arr.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (arr[mid].t < t) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the chart time of the first fretted event that can still affect
|
||||
* the camera at `now`, or the next fretted onset after it.
|
||||
*
|
||||
* This is intentionally a one-time full-chart scan. It runs only when a
|
||||
* new song/arrangement's arrays first arrive, allowing the camera to frame
|
||||
* the opening phrase during a silent intro instead of waiting for that
|
||||
* phrase to enter the live targeting window. Open strings do not define a
|
||||
* horizontal fret target, and malformed/out-of-range strings are ignored.
|
||||
*
|
||||
* Events already inside the behind-window, plus older sustains that are
|
||||
* still ringing at `now`, return `now` so bootstrap framing matches the
|
||||
* ordinary live path. Future events return their onset time.
|
||||
*/
|
||||
export function hwyFirstRelevantFrettedTime(notes, chords, now, behind, stringCount) {
|
||||
const nStrings = Number.isFinite(stringCount) ? Math.max(0, Math.floor(stringCount)) : 0;
|
||||
const cameraFloor = now - Math.max(0, Number(behind) || 0);
|
||||
let first = Infinity;
|
||||
|
||||
const validFretted = n => n
|
||||
&& n.f > 0
|
||||
&& Number.isInteger(n.s)
|
||||
&& n.s >= 0
|
||||
&& n.s < nStrings;
|
||||
const consider = (eventTime, sustain) => {
|
||||
const t = Number(eventTime);
|
||||
if (!Number.isFinite(t)) return;
|
||||
const sus = Number(sustain);
|
||||
const end = t + (Number.isFinite(sus) && sus > 0 ? sus : 0);
|
||||
if (t < cameraFloor && end < now) return;
|
||||
const relevantTime = t <= now ? now : t;
|
||||
if (relevantTime < first) first = relevantTime;
|
||||
};
|
||||
|
||||
if (notes) {
|
||||
for (const n of notes) {
|
||||
if (validFretted(n)) consider(n.t, n.sus);
|
||||
}
|
||||
}
|
||||
if (chords) {
|
||||
for (const ch of chords) {
|
||||
if (!ch || !ch.notes) continue;
|
||||
for (const cn of ch.notes) {
|
||||
if (validFretted(cn)) consider(ch.t, cn.sus);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Number.isFinite(first) ? first : null;
|
||||
}
|
||||
|
||||
// ── Fret mid — h3d-carve-1b ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* World-space X of the midpoint of fret column f.
|
||||
* f <= 0 → nut-side sentinel (−2K).
|
||||
* screen.js keeps `const fretMid = f => geoFretMid(f, _h3dFretUniform);`
|
||||
* so no call-site changes in screen.js.
|
||||
* @param {number} f fret number
|
||||
* @param {boolean} uniform true → uniform spacing; false → logarithmic
|
||||
*/
|
||||
export const geoFretMid = (f, uniform) => f <= 0 ? -2 * K : (geoFretX(f - 1, uniform) + geoFretX(f, uniform)) / 2;
|
||||
|
||||
// ── Gaussian bloom texture ────────────────────────────────────────────────────
|
||||
|
||||
// Build a horizontal gaussian DataTexture for the sustain-rail bloom effect.
|
||||
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u−0.5)²/σ²),
|
||||
// peaking at 1.0 in the centre. With the default σ=0.28 the edges retain
|
||||
// ~0.20 alpha (not fully transparent) — a deliberately soft, wide falloff
|
||||
// so the additive bloom fades gradually rather than cutting off sharply.
|
||||
// Power-of-two width keeps WebGL mipmapping happy.
|
||||
export function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
|
||||
const data = new Uint8Array(w * 4);
|
||||
for (let i = 0; i < w; i++) {
|
||||
const u = i / (w - 1);
|
||||
const d = (u - 0.5) / sigma;
|
||||
const v = Math.exp(-0.5 * d * d);
|
||||
const a = Math.round(v * 255);
|
||||
data[i * 4] = 255;
|
||||
data[i * 4 + 1] = 255;
|
||||
data[i * 4 + 2] = 255;
|
||||
data[i * 4 + 3] = a;
|
||||
}
|
||||
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
|
||||
// LinearFilter on both axes so the bloom plane interpolates smoothly
|
||||
// when scaled — the default NearestFilter causes visible banding.
|
||||
tex.magFilter = ThreeLib.LinearFilter;
|
||||
tex.minFilter = ThreeLib.LinearFilter;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
// src/materials.js — h3d-carve-6
|
||||
// Material builders extracted from screen.js N-section.
|
||||
//
|
||||
// screen.js usage (factory scope, once per createFactory() invocation):
|
||||
// const _techMatCache = new Map();
|
||||
// const { txtMat, pinchHarmonicMat, naturalHarmonicMat,
|
||||
// palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
|
||||
// triMat, bendChevronMat, darkenHex, slideArrowMat,
|
||||
// _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool } =
|
||||
// createMaterialBuilders({
|
||||
// getT, // () => T — live accessor (T is null before loadThree resolves)
|
||||
// getTxtCache, // () => txtCache — live accessor (txtCache reassigned to {} on teardown)
|
||||
// techMatCache, // stable const Map ref (screen.js factory-scope; teardown .values()/.clear())
|
||||
// techMeshMatClones, // stable const Set ref (screen.js factory-scope; teardown .clear())
|
||||
// });
|
||||
//
|
||||
// Surprises vs plan §4:
|
||||
// • DI is 4 params, not just { getT } — declared before commit
|
||||
// • _syncOpenStringPitchLabels cluster (20+ factory-scope deps) excluded from this cut
|
||||
// • _techMatCache stays in screen.js factory scope so teardown can call .values()/.clear() directly
|
||||
//
|
||||
// Beyond-subst rewires (4):
|
||||
// 1. Factory wrapper createMaterialBuilders({...})
|
||||
// 2. T → const T = getT() at the top of each function that needs Three.js
|
||||
// 3. txtCache[k] → const cache = getTxtCache(); cache[k]
|
||||
// 4. _techMatCache → techMatCache / _techMeshMatClones → techMeshMatClones (DI param names)
|
||||
// 5. _pmXSpriteMat, _fhXSpriteMat promoted to module closure (scope change only)
|
||||
|
||||
export function createMaterialBuilders({ getT, getTxtCache, techMatCache, techMeshMatClones }) {
|
||||
// ── Private closure vars (were factory-scope lets in screen.js N-section) ─
|
||||
let _pmXSpriteMat = null;
|
||||
let _fhXSpriteMat = null;
|
||||
|
||||
// ── Text-sprite style presets ─────────────────────────────────────────────
|
||||
// Each preset describes how a class of label is rasterised.
|
||||
// Tweak per-class look here (font, outline color/width, source
|
||||
// canvas size). `wide` toggles a long aspect ratio for multi-char
|
||||
// labels (chord/section names, "↑1/2", "~~~").
|
||||
//
|
||||
// Knobs:
|
||||
// font — full CSS font shorthand (weight + size + family)
|
||||
// wideFont — same, used when caller passes wide=true
|
||||
// srcH — source-canvas height in px (square; wide=4×).
|
||||
// Keep power-of-two so WebGL1 / Three.js retain
|
||||
// mipmaps + linear-mip-linear filtering — NPOT
|
||||
// textures silently fall back to no-mipmap and
|
||||
// shimmer at distance.
|
||||
// stroke — outline color (null = no outline)
|
||||
// strokeW — outline line-width in source-canvas px
|
||||
// shadow — { color, blur, dx, dy } or null
|
||||
const TXT_STYLES = {
|
||||
// The two fret-number sets the user wants to pop hardest.
|
||||
fretRow: {
|
||||
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
srcH: 256, stroke: '#0a1018', strokeW: 18,
|
||||
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
|
||||
},
|
||||
noteFret: {
|
||||
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
srcH: 256, stroke: '#0a1018', strokeW: 18,
|
||||
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
|
||||
},
|
||||
// Ghost-fret labels on the board projection: same weight/size/outline
|
||||
// as noteFret, but uses textAlign='center'; textBaseline='middle'
|
||||
// (the standard branch in txtMat) so the glyph is truly centred on
|
||||
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
|
||||
// intentionally NOT activated for this style — that path was designed
|
||||
// for Sprites and shifts the canvas origin, which causes visible
|
||||
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
|
||||
ghostFret: {
|
||||
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
|
||||
srcH: 256, stroke: '#0a1018', strokeW: 18,
|
||||
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
|
||||
},
|
||||
// Chord names — gold script-style label, lighter outline keeps
|
||||
// the colour readable.
|
||||
chord: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
// Section banners ("Verse", "Chorus") — same as chord weight.
|
||||
section: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
|
||||
technique: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
// Open-string "0" label on the note body itself.
|
||||
open: {
|
||||
font: 'bold 80px sans-serif',
|
||||
wideFont: 'bold 64px sans-serif',
|
||||
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
|
||||
},
|
||||
};
|
||||
|
||||
function txtMat(text, col, wide, style) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const sName = style || 'technique';
|
||||
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
|
||||
if (cache[k]) return cache[k];
|
||||
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
|
||||
const h = sp.srcH;
|
||||
const str = String(text);
|
||||
const font = wide ? sp.wideFont : sp.font;
|
||||
|
||||
let w = wide ? h * 4 : h;
|
||||
|
||||
if (!wide && sName === 'noteFret') {
|
||||
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
|
||||
// glyphs stay centred at (w/2, h/2) without edge clipping.
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
probe.font = font;
|
||||
const tw = probe.measureText(str).width;
|
||||
let pad = 0;
|
||||
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
|
||||
if (sp.shadow) {
|
||||
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
|
||||
}
|
||||
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
|
||||
}
|
||||
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
x.font = font;
|
||||
// Fret / open-string digits: anchor from actualBoundingBox so the
|
||||
// glyph sits at the true optical centre of the canvas (fixes
|
||||
// sprites looking off-centre inside the board ghost and elsewhere).
|
||||
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
|
||||
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
|
||||
// billboard. Sprites tolerate slight canvas off-centering because Three.js
|
||||
// centres them at their world position; a Mesh does not — the digit lands
|
||||
// wherever it sits in UV space. Use the advance-width centre as the initial
|
||||
// pen position and then correct for any ink asymmetry via actualBoundingBox.
|
||||
const inkCenterGhost = !wide && sName === 'ghostFret';
|
||||
let drawX = w / 2;
|
||||
let drawY = h / 2;
|
||||
if (inkCenterFret) {
|
||||
x.textAlign = 'left';
|
||||
x.textBaseline = 'alphabetic';
|
||||
const m = x.measureText(str);
|
||||
const L = m.actualBoundingBoxLeft;
|
||||
const R = m.actualBoundingBoxRight;
|
||||
const A = m.actualBoundingBoxAscent;
|
||||
const D = m.actualBoundingBoxDescent;
|
||||
if (
|
||||
L != null && R != null && A != null && D != null &&
|
||||
Number.isFinite(L) && Number.isFinite(R) &&
|
||||
Number.isFinite(A) && Number.isFinite(D)
|
||||
) {
|
||||
const inkW = R - L;
|
||||
drawX = (w - inkW) / 2 - L;
|
||||
drawY = (h + A - D) / 2;
|
||||
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
|
||||
// small canvas nudge keeps sprites centred on the board ghost.
|
||||
if (sName === 'noteFret') drawY -= h * 0.028;
|
||||
} else {
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
drawX = w / 2;
|
||||
drawY = h / 2;
|
||||
}
|
||||
} else if (inkCenterGhost) {
|
||||
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
|
||||
// stroke) to find the true ink centre of mass without contamination
|
||||
// from the isotropic shadow blur. For Arial Black "1" the shadow from
|
||||
// the thin upper-left flag bleeds leftward and cancels part of the
|
||||
// rightward correction when we include it in the scan. Measuring fill
|
||||
// alone isolates the actual glyph shape.
|
||||
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
|
||||
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
|
||||
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
|
||||
// Max 4 unique digits (1–4) → cache-miss runs at most 4 times ever.
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
try {
|
||||
const tmpC = document.createElement('canvas');
|
||||
tmpC.width = w; tmpC.height = h;
|
||||
const tc = tmpC.getContext('2d');
|
||||
tc.font = font;
|
||||
tc.textAlign = 'center';
|
||||
tc.textBaseline = 'middle';
|
||||
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
|
||||
// and muddles the centroid; fill alone gives the cleanest reading.
|
||||
tc.fillStyle = '#ffffff';
|
||||
tc.fillText(str, w / 2, h / 2);
|
||||
const id = tc.getImageData(0, 0, w, h).data;
|
||||
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
|
||||
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
|
||||
// correct perceptual centre rather than the geometric bbox midpoint.
|
||||
let sumX = 0, sumY = 0, sumA = 0;
|
||||
for (let py = 0; py < h; py++) {
|
||||
for (let px = 0; px < w; px++) {
|
||||
const a = id[(py * w + px) * 4 + 3];
|
||||
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
|
||||
}
|
||||
}
|
||||
if (sumA > 0) {
|
||||
// shift pen so centroid → canvas centre, then add a small
|
||||
// extra rightward nudge (8 %) so the vertical stroke of
|
||||
// narrow digits like "1" sits visually at gem centre rather
|
||||
// than the advance-width centre (which may be slightly left
|
||||
// of the dominant ink mass for Arial Black numerals).
|
||||
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
|
||||
drawY = h / 2 + (h / 2 - sumY / sumA);
|
||||
}
|
||||
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
|
||||
// x (real canvas) still has textAlign='center'; textBaseline='middle'
|
||||
} else {
|
||||
x.textAlign = 'center';
|
||||
x.textBaseline = 'middle';
|
||||
}
|
||||
if (sp.shadow) {
|
||||
x.shadowColor = sp.shadow.color;
|
||||
x.shadowBlur = sp.shadow.blur;
|
||||
x.shadowOffsetX = sp.shadow.dx;
|
||||
x.shadowOffsetY = sp.shadow.dy;
|
||||
}
|
||||
if (sp.stroke && sp.strokeW > 0) {
|
||||
x.lineJoin = 'round';
|
||||
x.miterLimit = 2;
|
||||
x.strokeStyle = sp.stroke;
|
||||
x.lineWidth = sp.strokeW;
|
||||
x.strokeText(str, drawX, drawY);
|
||||
}
|
||||
x.fillStyle = col;
|
||||
x.fillText(str, drawX, drawY);
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
// depthTest:false means later geometry never *fails* depth
|
||||
// against these sprites, but without depthWrite:false the
|
||||
// sprites still write to the depth buffer (Three.js default
|
||||
// is depthWrite:true even for SpriteMaterial). That can
|
||||
// make subsequent sprites/labels vanish — match the
|
||||
// pattern used by the other sprite materials in this file.
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
function pinchHarmonicMat(col) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const baseCol = new T.Color(col != null ? col : '#ffd84d');
|
||||
// v5 — compact concentric ellipses:
|
||||
// 1. black outer border rx=0.430h ry=0.255h
|
||||
// 2. string-color body rx=0.418h ry=0.232h
|
||||
// 3. black inner ring rx=0.407h ry=0.218h
|
||||
// 4. string-color inner rx=0.264h ry=0.218h
|
||||
// 5. black center dot rx=0.134h ry=0.120h
|
||||
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
|
||||
if (cache[k]) return cache[k];
|
||||
|
||||
const h = 512;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = h; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
const TAU = Math.PI * 2;
|
||||
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
|
||||
|
||||
x.clearRect(0, 0, h, h);
|
||||
x.save();
|
||||
x.translate(h / 2, h / 2);
|
||||
|
||||
// Form 1 — black outer border
|
||||
x.fillStyle = '#000000';
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 2 — string-color main body
|
||||
x.fillStyle = colStr;
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 3 — black inner ring
|
||||
x.fillStyle = '#000000';
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 4 — string-color inner spot (narrower)
|
||||
x.fillStyle = colStr;
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
|
||||
|
||||
// Form 5 — black center dot
|
||||
x.fillStyle = '#000000';
|
||||
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
|
||||
|
||||
x.restore();
|
||||
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
function naturalHarmonicMat() {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
|
||||
if (cache[k]) return cache[k];
|
||||
|
||||
const h = 256;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = h; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
const cx = h / 2;
|
||||
const cy = h / 2;
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
x.clearRect(0, 0, h, h);
|
||||
|
||||
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
|
||||
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
|
||||
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
|
||||
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
x.fillStyle = glow;
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.44, 0, TAU);
|
||||
x.fill();
|
||||
|
||||
x.shadowColor = 'rgba(0,0,0,0.85)';
|
||||
x.shadowBlur = 14;
|
||||
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.31, 0, TAU);
|
||||
x.fill();
|
||||
|
||||
// Punch out the inner gap so the icon reads as a bright ring.
|
||||
x.shadowBlur = 0;
|
||||
x.globalCompositeOperation = 'destination-out';
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.20, 0, TAU);
|
||||
x.fill();
|
||||
x.globalCompositeOperation = 'source-over';
|
||||
|
||||
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
|
||||
x.shadowBlur = 10;
|
||||
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
|
||||
x.lineWidth = 8;
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.255, 0, TAU);
|
||||
x.stroke();
|
||||
|
||||
x.shadowColor = 'rgba(0,0,0,0)';
|
||||
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
|
||||
x.beginPath();
|
||||
x.arc(cx, cy, h * 0.12, 0, TAU);
|
||||
x.fill();
|
||||
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
opacity: 0.96,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Only two PM/FH variants exist (palm-mute = black-on-white,
|
||||
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
|
||||
// muted chord-note per frame, so dense PM/FH passages were paying
|
||||
// for a string concat + Map lookup on every call. Hoist both
|
||||
// SpriteMaterial refs and short-circuit before touching the cache.
|
||||
// They're populated lazily on first use; teardown still reaches
|
||||
// them via the shared ``txtCache`` because muteXMat writes there.
|
||||
function palmMuteXSpriteMat() {
|
||||
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
|
||||
}
|
||||
function fretHandMuteXSpriteMat() {
|
||||
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
|
||||
}
|
||||
|
||||
function muteXMat(fillCol, strokeCol) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
|
||||
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
|
||||
if (cache[k]) return cache[k];
|
||||
|
||||
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
|
||||
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
|
||||
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
|
||||
// using outerW/2, which is too small). With the correct pad the white
|
||||
// cap is fully inside the canvas and the border is visible at every tip.
|
||||
const h = 512;
|
||||
const outerW = 132, innerW = 114;
|
||||
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
|
||||
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
|
||||
const c = document.createElement('canvas');
|
||||
c.width = h; c.height = h;
|
||||
const x = c.getContext('2d');
|
||||
|
||||
x.clearRect(0, 0, h, h);
|
||||
x.lineCap = 'square';
|
||||
|
||||
// Draw each diagonal in its own stroke() call — caps of the two
|
||||
// diagonals don't interact, and the white outer is drawn before the
|
||||
// black inner so the border is clean at every edge and tip.
|
||||
x.strokeStyle = strokeCol;
|
||||
x.lineWidth = outerW;
|
||||
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
|
||||
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
|
||||
|
||||
x.strokeStyle = fillCol;
|
||||
x.lineWidth = innerW;
|
||||
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
|
||||
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
|
||||
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c),
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
cache[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Technique-marker sprite materials (triangle / chevron). Keyed by a
|
||||
// packed NUMBER, not a string — triMat/bendChevronMat are called from
|
||||
// the drawNote hot path, so a string cache key would allocate per
|
||||
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
|
||||
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
|
||||
// step-count) so triangle and chevron entries can't collide.
|
||||
|
||||
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down)
|
||||
// with a thick border in the gem's string colour.
|
||||
function triMat(up, hex) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const key = h * 16 + (up ? 0 : 1);
|
||||
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
|
||||
if (cached) return cached;
|
||||
const S = 256, m = S * 0.15;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.beginPath();
|
||||
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
|
||||
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
|
||||
g.closePath();
|
||||
g.lineJoin = 'round';
|
||||
g.fillStyle = '#ffffff';
|
||||
g.fill();
|
||||
g.lineWidth = S * 0.122;
|
||||
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
|
||||
g.stroke();
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c), transparent: true,
|
||||
depthTest: false, depthWrite: false,
|
||||
});
|
||||
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
|
||||
// string colour (chart-format bend notation — 1 per half-step).
|
||||
function bendChevronMat(steps, hex) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
|
||||
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
|
||||
if (cached) return cached;
|
||||
const S = 256;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
|
||||
g.lineWidth = S * 0.10;
|
||||
g.lineJoin = g.lineCap = 'round';
|
||||
const padX = S * 0.18;
|
||||
const rowH = S / steps;
|
||||
const amp = Math.min(rowH * 0.55, S * 0.24);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const cy = (i + 0.5) * rowH;
|
||||
g.beginPath();
|
||||
g.moveTo(padX, cy + amp * 0.5);
|
||||
g.lineTo(S / 2, cy - amp * 0.5);
|
||||
g.lineTo(S - padX, cy + amp * 0.5);
|
||||
g.stroke();
|
||||
}
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c), transparent: true,
|
||||
depthTest: false, depthWrite: false,
|
||||
});
|
||||
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
|
||||
// marker — full string colour is too bright next to the gem.
|
||||
function darkenHex(hex, factor) {
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const r = Math.round(((h >> 16) & 0xff) * factor);
|
||||
const g = Math.round(((h >> 8) & 0xff) * factor);
|
||||
const b = Math.round((h & 0xff) * factor);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
// Slide-direction arrow (›/‹): a filled triangle pointing toward the
|
||||
// slide's destination fret, in the gem's (darkened) string colour.
|
||||
// `hex` here is already the darkened colour — keep its own cache-key
|
||||
// nibble range (8/9) so it can't collide with triMat (0/1) or
|
||||
// bendChevronMat (3-6).
|
||||
function slideArrowMat(pointRight, hex) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
const h = (hex >>> 0) & 0xffffff;
|
||||
const key = h * 16 + 8 + (pointRight ? 0 : 1);
|
||||
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
|
||||
if (cached) return cached;
|
||||
const S = 256, m = S * 0.18;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const g = c.getContext('2d');
|
||||
g.beginPath();
|
||||
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
|
||||
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
|
||||
g.closePath();
|
||||
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
|
||||
g.fill();
|
||||
const mat = new T.SpriteMaterial({
|
||||
map: new T.CanvasTexture(c), transparent: true,
|
||||
depthTest: false, depthWrite: false,
|
||||
});
|
||||
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
|
||||
return mat;
|
||||
}
|
||||
|
||||
function _meshMatForGhostFretDigit(spriteMat) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
let mb = spriteMat.userData.h3dGhostFretMeshMat;
|
||||
if (!mb) {
|
||||
mb = new T.MeshBasicMaterial({
|
||||
map: spriteMat.map,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
spriteMat.userData.h3dGhostFretMeshMat = mb;
|
||||
}
|
||||
return mb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
|
||||
* texture, so technique markers can be applied to a rotatable PlaneGeometry
|
||||
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
|
||||
*
|
||||
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
|
||||
* clone> so a recycled mesh that's used for several techniques
|
||||
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
|
||||
* clone for each one rather than disposing-and-recloning on every
|
||||
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
|
||||
* markers this collapses the per-frame allocation entirely while
|
||||
* still being bounded — the per-mesh Map has at most one entry per
|
||||
* distinct technique × colour the mesh has ever been used for.
|
||||
*/
|
||||
function _spriteMat2MeshMat(mesh, sm) {
|
||||
const T = getT(); // beyond-subst: T via live accessor
|
||||
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
|
||||
if (perMesh) {
|
||||
const hit = perMesh.get(sm.map);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
let base = sm.userData.h3dTechMeshMat;
|
||||
if (!base) {
|
||||
base = new T.MeshBasicMaterial({
|
||||
map: sm.map,
|
||||
transparent: true,
|
||||
// depthTest: false — cross-note Z ordering is handled by
|
||||
// per-note renderOrderForLayerAtZ(...) calls rather than the
|
||||
// depth buffer. This is necessary because close notes often use
|
||||
// mGlow (depthWrite:false), so the depth buffer can't reliably
|
||||
// occlude far markers near the hit line. With per-note renderOrder,
|
||||
// far labels render first and close note geometry renders last,
|
||||
// appearing on top without depthTest.
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
// forceSinglePass accompanies EVERY transparent DoubleSide
|
||||
// material in this file: without it, Three r158+ renders
|
||||
// each such object in TWO passes (back side then front),
|
||||
// setting material.needsUpdate on both — which forces a
|
||||
// full getParameters/program-cache lookup per object per
|
||||
// frame (profiled at ~4% of throttled main-thread time)
|
||||
// and doubles the draw calls. The two-pass path exists to
|
||||
// fix self-occlusion sorting on closed transparent meshes;
|
||||
// all our DoubleSide materials are flat unlit quads
|
||||
// (labels, rails, frames, lanes) where it buys nothing.
|
||||
side: T.DoubleSide, forceSinglePass: true,
|
||||
});
|
||||
sm.userData.h3dTechMeshMat = base;
|
||||
}
|
||||
// First conversion for this mesh: the pTechPlane pool factory gave
|
||||
// it a placeholder MeshBasicMaterial that the caller is about to
|
||||
// overwrite with the clone below. Dispose it now — once
|
||||
// mesh.material is reassigned the placeholder is orphaned and
|
||||
// teardown's scene.traverse() pass can no longer reach it, so it
|
||||
// would leak one GPU material per pooled mesh for the renderer's
|
||||
// lifetime.
|
||||
if (!perMesh && mesh.material && mesh.material !== base) {
|
||||
mesh.material.dispose?.();
|
||||
}
|
||||
if (!perMesh) {
|
||||
perMesh = new Map();
|
||||
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
|
||||
}
|
||||
const clone = base.clone();
|
||||
perMesh.set(sm.map, clone);
|
||||
techMeshMatClones.add(clone); // beyond-subst: _techMeshMatClones → techMeshMatClones
|
||||
return clone;
|
||||
}
|
||||
|
||||
function pool(parent, mk) {
|
||||
const a = [];
|
||||
let n = 0;
|
||||
return {
|
||||
get() {
|
||||
if (n < a.length) {
|
||||
const o = a[n++];
|
||||
o.visible = true;
|
||||
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
|
||||
return o;
|
||||
}
|
||||
const o = mk(); parent.add(o); a.push(o); n++; return o;
|
||||
},
|
||||
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
|
||||
// Pre-allocate `cap` slots at construction so the first dense
|
||||
// playback frames don't pay the new-Mesh allocation cost
|
||||
// mid-RAF (felt as a stall on 7/8-string charts where the
|
||||
// visible-note count outruns the lazy-grow path). Lazy growth
|
||||
// past `cap` still works — this is amortisation, not a cap.
|
||||
//
|
||||
// Coerce `cap` to a non-negative int32: a float would still
|
||||
// work but a callsite passing `Infinity` (or `NaN`) would
|
||||
// otherwise spin the while-loop until OOM. `cap | 0`
|
||||
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
|
||||
// Math.max(0, …) keeps negatives out.
|
||||
warm(cap) {
|
||||
// Local rename to avoid shadowing the pool's outer
|
||||
// `n` (the in-use index advanced by get() / reset()).
|
||||
const targetLen = Math.max(0, cap | 0);
|
||||
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
txtMat, pinchHarmonicMat, naturalHarmonicMat,
|
||||
palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
|
||||
triMat, bendChevronMat, darkenHex, slideArrowMat,
|
||||
_meshMatForGhostFretDigit, _spriteMat2MeshMat, pool,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,924 @@
|
||||
// src/overlay.js — h3d-carve-7
|
||||
// Lyrics + HUD overlay extracted from screen.js O-section (lines 4338–5209
|
||||
// post-cut-6). Pure 2D canvas drawing — no Three.js dependency.
|
||||
//
|
||||
// screen.js usage (factory scope, once per createFactory() invocation):
|
||||
// const { drawChordDiagram, _drawDiagramCached, drawSectionHud,
|
||||
// drawToneHud, drawLyrics } = createOverlay({
|
||||
// diagRenderCache, // stable const Map ref (screen.js factory-scope)
|
||||
// // teardown calls .clear() on it directly; the shared
|
||||
// // ref makes that visible to the overlay without a callback
|
||||
// });
|
||||
//
|
||||
// Surprises vs contract:
|
||||
// • longestConsecutiveRun (lines 4338–4352) co-moved — only called by drawChordDiagram
|
||||
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX moved here and DELETED from
|
||||
// screen.js lines 573–575 (only used inside O-section)
|
||||
// • _DIAG_CACHE_MAX moved inside createOverlay body and deleted from
|
||||
// screen.js factory scope (line 3256) — same factory-scope level, different file
|
||||
//
|
||||
// Beyond-subst rewires (2):
|
||||
// 1. Factory wrapper createOverlay({ diagRenderCache })
|
||||
// 2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
|
||||
|
||||
// ── Constants moved from screen.js module scope (lines 573–575) ─────────────
|
||||
// Only users were inside the O-section; no other callers remain in screen.js.
|
||||
const DIAG_SIZE_MIN = 0.08;
|
||||
const DIAG_SIZE_MAX = 0.16;
|
||||
const DIAG_CELL_MAX = 34;
|
||||
|
||||
export function createOverlay({ diagRenderCache }) {
|
||||
// ── Moved from screen.js factory scope (line 3256) ───────────────────────
|
||||
// Cap chosen to cover the ~5–6 active chord shapes per phrase while
|
||||
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
|
||||
// panel at typical 1920×1080). A structural fix — caching a
|
||||
// tightly-sized box surface instead of the full overlay canvas —
|
||||
// is tracked as a follow-up.
|
||||
const _DIAG_CACHE_MAX = 6;
|
||||
|
||||
// ── Lyrics layout cache — moved from factory scope (line 5077) ───────────
|
||||
// measureText per syllable + row wrapping only changes when the displayed
|
||||
// line(s), font size, or canvas width change, not per frame.
|
||||
let _lyrRowsCache = null;
|
||||
|
||||
// Returns indices of the longest consecutive run in a sorted integer
|
||||
// array as { start, len } — `sorted[start..start+len)` is the run.
|
||||
// Avoids the two per-call sub-array allocations of the previous
|
||||
// implementation (best + cur arrays grown via .push), at the cost
|
||||
// of one small 2-key result object. Net: callers in the chord-
|
||||
// diagram render path no longer churn arrays per visible chord.
|
||||
function longestConsecutiveRun(sorted) {
|
||||
let bestStart = -1, bestLen = 0;
|
||||
let curStart = -1, curLen = 0;
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
|
||||
if (curLen === 0) curStart = i;
|
||||
curLen++;
|
||||
} else {
|
||||
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
||||
curStart = i; curLen = 1;
|
||||
}
|
||||
}
|
||||
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
||||
return { start: bestStart, len: bestLen };
|
||||
}
|
||||
|
||||
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
|
||||
function drawChordDiagram(ctx, opts) {
|
||||
const {
|
||||
name, frets,
|
||||
opacity = 1,
|
||||
entranceT = 1.0,
|
||||
canvasW = 600, canvasH = 400,
|
||||
inverted = false,
|
||||
sizeSlider = 0.5,
|
||||
position = 'tl',
|
||||
nStr = 6,
|
||||
lyricsBottom = 0,
|
||||
stackOffset = 0,
|
||||
} = opts;
|
||||
|
||||
// Responsive sizing — CELL derived from panel height + user slider.
|
||||
// COLS is the resolved string count from the caller (via resolveStringCount)
|
||||
// so bass (4), extended (7/8) arrangements render correctly.
|
||||
const COLS = nStr, ROWS = 4;
|
||||
// Minimum column span required for PATH B (bracket extension / detection).
|
||||
// Math.min(COLS-1, 4) scales with string count:
|
||||
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
|
||||
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
|
||||
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
|
||||
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
|
||||
// Maps diagram column index → chord-template frets-array index.
|
||||
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
|
||||
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
|
||||
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
|
||||
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
|
||||
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
|
||||
|
||||
// startFret / isFirstPos must be known before CELL so that fretLabelW
|
||||
// can be measured and factored into the width cap. The old
|
||||
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
|
||||
// extra left padding reserved for non-first-position "Nfr" labels.
|
||||
const playedFrets = frets.filter(f => f > 0);
|
||||
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
|
||||
const startFret = Math.max(1, minFret);
|
||||
const isFirstPos = startFret === 1;
|
||||
|
||||
// Phase 1 — height + hard-cap estimate, used only to size the label font.
|
||||
// Cap against the vertical space available below lyricsBottom so that the
|
||||
// diagram does not overflow into the lyrics banner on short split panels with
|
||||
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
|
||||
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
|
||||
// and 'br' the full canvas height is available.
|
||||
// Clamp to at least 1 so font/box calculations never receive 0-px input
|
||||
// on very short panels (e.g. tiny split cells < 44 px tall).
|
||||
const isTopCorner = position === 'tl' || position === 'tr';
|
||||
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
|
||||
const cellEst = Math.max(1, Math.min(
|
||||
Math.round(availH * sizeF / (ROWS + 3)),
|
||||
DIAG_CELL_MAX,
|
||||
));
|
||||
// Extra left padding for the "Nfr" label on non-first-position chords.
|
||||
// Measured with ctx.measureText at cellEst so the estimate is exact.
|
||||
let fretLabelW = 0;
|
||||
if (!isFirstPos) {
|
||||
// Measure inside a save/restore so this font assignment does not
|
||||
// leak to the caller (the outer ctx.save() happens after CELL is derived).
|
||||
ctx.save();
|
||||
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
|
||||
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
|
||||
// Two width constraints are needed because PAD has a hard floor of 6:
|
||||
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
|
||||
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
|
||||
// Both are included so boxW ≤ canvasW in every regime.
|
||||
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
|
||||
const CELL = Math.max(1, Math.min(
|
||||
cellEst,
|
||||
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
|
||||
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
|
||||
));
|
||||
const HEADER = Math.round(CELL * 1.6);
|
||||
const MARKER = Math.round(CELL * 0.7);
|
||||
const DOT_R = CELL * 0.3;
|
||||
const PAD = Math.max(6, Math.round(CELL * 0.65));
|
||||
const gridW = CELL * (COLS - 1);
|
||||
const gridH = CELL * ROWS;
|
||||
|
||||
const PAD_L = PAD + fretLabelW;
|
||||
|
||||
const boxW = gridW + PAD_L + PAD;
|
||||
const boxH = HEADER + MARKER + gridH + PAD;
|
||||
|
||||
// Anchor to chosen corner. Top positions get extra vertical offset
|
||||
// to clear the timeline plugin and song name displayed at the top.
|
||||
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
|
||||
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
|
||||
// how many wrap lines the current panel width produces.
|
||||
const E = PAD;
|
||||
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
||||
let bx, by;
|
||||
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
||||
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
||||
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
||||
else { bx = E; by = TOP_Y + stackOffset; }
|
||||
|
||||
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
|
||||
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
||||
by = Math.max(0, Math.min(canvasH - boxH, by));
|
||||
|
||||
// Guard: the canvasH–boxH clamp above can push `by` above lyricsBottom when
|
||||
// wrapped lyrics consume nearly the full panel height. This applies to ALL
|
||||
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
|
||||
// still reach up into the lyrics banner on very short or narrow panels where
|
||||
// boxH is larger than the space below the lyrics. In those cases skip drawing
|
||||
// entirely rather than painting on top of the lyrics banner.
|
||||
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
||||
|
||||
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
|
||||
|
||||
// Ease-out quadratic entrance scale: 0.85 → 1.0.
|
||||
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
if (scale !== 1.0) {
|
||||
const cx = bx + boxW / 2, cy = by + boxH / 2;
|
||||
ctx.translate(cx, cy);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-cx, -cy);
|
||||
}
|
||||
|
||||
// Background + border.
|
||||
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
||||
|
||||
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
|
||||
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
|
||||
const root = rootMatch ? rootMatch[1] : name;
|
||||
const quality = rootMatch ? rootMatch[2] : '';
|
||||
const rootSize = Math.round(CELL * 1.25);
|
||||
const qualSize = Math.round(rootSize * 0.65);
|
||||
ctx.textBaseline = 'middle';
|
||||
const nameY = by + HEADER * 0.55;
|
||||
ctx.font = `bold ${rootSize}px sans-serif`;
|
||||
const rootW = ctx.measureText(root).width;
|
||||
ctx.font = `${qualSize}px sans-serif`;
|
||||
const qualW = quality ? ctx.measureText(quality).width : 0;
|
||||
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
|
||||
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
|
||||
ctx.fillStyle = '#e8d080';
|
||||
ctx.font = `bold ${rootSize}px sans-serif`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(root, nameStartX, nameY);
|
||||
if (quality) {
|
||||
ctx.font = `${qualSize}px sans-serif`;
|
||||
ctx.fillStyle = 'rgba(232,208,128,0.75)';
|
||||
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
|
||||
}
|
||||
|
||||
// Nut: CELL-proportional filled rect + subtle highlight line.
|
||||
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
|
||||
// the smallest diagrams (CELL=1 on compact split panels).
|
||||
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
|
||||
if (isFirstPos) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
|
||||
}
|
||||
|
||||
// Fret label for non-first-position chords.
|
||||
if (!isFirstPos) {
|
||||
ctx.fillStyle = 'rgba(220,200,120,0.9)';
|
||||
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
|
||||
}
|
||||
|
||||
// Fret lines.
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
|
||||
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gx, gy + r * CELL);
|
||||
ctx.lineTo(gx + gridW, gy + r * CELL);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// String lines with varying weight: low E heavier, high e lighter.
|
||||
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
|
||||
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
|
||||
// Weights scale with CELL so strings never bleed into adjacent columns on
|
||||
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const strIdx = getStrIdx(col);
|
||||
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
|
||||
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gx + col * CELL, gy);
|
||||
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Barre detection — two complementary paths:
|
||||
//
|
||||
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
|
||||
// Bracket is initially set to the consecutive run's own endpoints (not the full
|
||||
// startFretCols range) so isolated bass notes at the same fret can't pull the
|
||||
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
|
||||
//
|
||||
// PATH B (full-span barre / extension):
|
||||
// When PATH A fired: extend the bracket outward to the full outer startFret span
|
||||
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
|
||||
// columns is fretted (f > 0).
|
||||
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
|
||||
// where only the two outermost strings sit at startFret. An additional check
|
||||
// ensures that no intermediate column is itself at startFret — this rules out
|
||||
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
|
||||
// incorrectly) while still catching B-major-style shapes where the barre
|
||||
// finger covers only the outer two strings.
|
||||
//
|
||||
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
|
||||
// Examples (6-string, MIN_BARRE_SPAN=4):
|
||||
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
|
||||
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
|
||||
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
|
||||
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
|
||||
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
|
||||
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
|
||||
const startFretCols = [];
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
|
||||
}
|
||||
const barreRun = longestConsecutiveRun(startFretCols);
|
||||
let hasBarreArc = barreRun.len >= 2; // PATH A
|
||||
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
|
||||
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
|
||||
|
||||
if (startFretCols.length >= 2) { // PATH B
|
||||
const minC = startFretCols[0];
|
||||
const maxC = startFretCols[startFretCols.length - 1];
|
||||
if (maxC - minC >= MIN_BARRE_SPAN) {
|
||||
let allFretted = true;
|
||||
for (let col = minC; col <= maxC; col++) {
|
||||
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
|
||||
}
|
||||
if (allFretted) {
|
||||
if (hasBarreArc) {
|
||||
// PATH A fired: always safe to extend to full outer span.
|
||||
barreMinCol = minC;
|
||||
barreMaxCol = maxC;
|
||||
} else {
|
||||
// PATH A did not fire: only draw a bracket when no intermediate
|
||||
// column sits at startFret. Intermediate startFret columns would
|
||||
// indicate a scattered/alternating voicing rather than a clean
|
||||
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
|
||||
let noInnerAtStartFret = true;
|
||||
for (let col = minC + 1; col < maxC; col++) {
|
||||
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
|
||||
}
|
||||
if (noInnerAtStartFret) {
|
||||
hasBarreArc = true;
|
||||
barreMinCol = minC;
|
||||
barreMaxCol = maxC;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasBarreArc) {
|
||||
const barreY = gy + CELL * 0.5;
|
||||
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
|
||||
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
|
||||
// Straight bracket: a horizontal line with short vertical end caps.
|
||||
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
|
||||
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
|
||||
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
|
||||
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Open/muted markers + finger dots.
|
||||
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
|
||||
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const f = frets[getStrIdx(col)];
|
||||
const sx = gx + col * CELL;
|
||||
const markerY = gy - MARKER * 0.5;
|
||||
if (f < 0) {
|
||||
const r = CELL * 0.20;
|
||||
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
|
||||
} else if (f === 0) {
|
||||
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
|
||||
} else {
|
||||
const row = f - startFret;
|
||||
if (row >= 0 && row < ROWS) {
|
||||
const isBarreCol = hasBarreArc && f === startFret &&
|
||||
col >= barreMinCol && col <= barreMaxCol;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||
ctx.shadowBlur = Math.min(4, CELL * 0.4);
|
||||
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
|
||||
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
|
||||
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
return boxH;
|
||||
}
|
||||
|
||||
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
|
||||
// transform is identity) the diagram is rendered once to an
|
||||
// OffscreenCanvas and reused every subsequent frame via drawImage +
|
||||
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
|
||||
// the scale transform is non-trivial so we fall through to a fresh
|
||||
// render — that window is ~12 frames at 60 fps, negligible.
|
||||
//
|
||||
// Returns boxH (diagram card height in px) so the draw loop can
|
||||
// accumulate per-corner stack offsets when multiple overlays share
|
||||
// the same corner position.
|
||||
function _drawDiagramCached(ctx, opts) {
|
||||
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
|
||||
if (opacity <= 0) return 0;
|
||||
if (entranceT < 1.0) {
|
||||
return drawChordDiagram(ctx, opts) || 0;
|
||||
}
|
||||
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
|
||||
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
|
||||
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
|
||||
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
|
||||
let entry = diagRenderCache.get(key);
|
||||
if (!entry) {
|
||||
let oc;
|
||||
try { oc = new OffscreenCanvas(canvasW, canvasH); }
|
||||
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
|
||||
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
|
||||
if (diagRenderCache.size >= _DIAG_CACHE_MAX) {
|
||||
diagRenderCache.delete(diagRenderCache.keys().next().value);
|
||||
}
|
||||
entry = { oc, boxH };
|
||||
diagRenderCache.set(key, entry);
|
||||
}
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.drawImage(entry.oc, 0, 0);
|
||||
ctx.restore();
|
||||
return entry.boxH;
|
||||
}
|
||||
|
||||
// Two-line section card. Top line is "Now: <current>", bottom line
|
||||
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
|
||||
// current vs upcoming — earlier single-line variant rendered both
|
||||
// states with the same word and was confusing during playback.
|
||||
//
|
||||
// Returns boxH on draw, 0 when nothing rendered. Position / size
|
||||
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
|
||||
// anchor corners, sizeSlider in [0,1] scales card height.
|
||||
//
|
||||
// Hidden when:
|
||||
// - no sections array, or
|
||||
// - playback has not yet reached the first section AND there's
|
||||
// no upcoming-only fallback rendered (we still show "Up Next"
|
||||
// during the pre-roll so the user sees what's coming).
|
||||
function drawSectionHud(ctx, opts) {
|
||||
const {
|
||||
sections, currentTime,
|
||||
canvasW, canvasH,
|
||||
position = 'tr',
|
||||
sizeSlider = 0.5,
|
||||
lyricsBottom = 0,
|
||||
stackOffset = 0,
|
||||
} = opts;
|
||||
if (!sections || !sections.length) return 0;
|
||||
|
||||
// sections are time-ordered server-side; single forward scan.
|
||||
let curIdx = -1;
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
if (sections[i].time <= currentTime) curIdx = i;
|
||||
else break;
|
||||
}
|
||||
const cur = curIdx >= 0 ? sections[curIdx] : null;
|
||||
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
|
||||
// Pre-first-section: nothing playing yet but next is coming —
|
||||
// still useful to render "Up Next" alone so the user gets the
|
||||
// anticipatory cue during the song's intro silence.
|
||||
if (!cur && !next) return 0;
|
||||
|
||||
const nowName = cur ? cur.name : '';
|
||||
// Render countdown as a separate span so it can take a calmer
|
||||
// grey-white treatment while the section name itself stays
|
||||
// cyan. Combining them into one string would inherit the cyan
|
||||
// fill across both, defeating the visual hierarchy promised
|
||||
// in the FR.
|
||||
let nextName = '';
|
||||
let nextCountdown = '';
|
||||
if (next) {
|
||||
const dt = next.time - currentTime;
|
||||
nextName = next.name;
|
||||
nextCountdown = dt > 10
|
||||
? 'in ' + Math.round(dt) + 's'
|
||||
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
|
||||
}
|
||||
|
||||
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
|
||||
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
|
||||
const PAD_X = Math.round(baseH * 0.45);
|
||||
const PAD_Y = Math.round(baseH * 0.20);
|
||||
// Per-text-element scale applied to nameSize / tagSize / lineH
|
||||
// when the unscaled card would overflow a narrow panel
|
||||
// (splitscreen quad layout, ultra-tall portrait). Computed
|
||||
// below from the measured contentW vs the available width.
|
||||
let textScale = 1.0;
|
||||
const baseLineH = Math.round(baseH * 0.46);
|
||||
const baseNameSize = Math.round(baseH * 0.36);
|
||||
const baseTagSize = Math.round(baseH * 0.24);
|
||||
const baseTagGap = Math.round(baseH * 0.14);
|
||||
|
||||
const TAG_NOW = 'Now:';
|
||||
const TAG_NEXT = 'Up Next:';
|
||||
|
||||
// Phase-1 measurement at the unscaled font sizes — used to
|
||||
// decide whether textScale needs to drop, and to lay out the
|
||||
// final draw at whatever scale we land on.
|
||||
ctx.save();
|
||||
ctx.font = `${baseTagSize}px sans-serif`;
|
||||
const tagNowWBase = ctx.measureText(TAG_NOW).width;
|
||||
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
|
||||
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||
ctx.font = `bold ${baseNameSize}px sans-serif`;
|
||||
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
|
||||
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
|
||||
ctx.restore();
|
||||
|
||||
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
|
||||
const lineNextWBase = nextName
|
||||
? tagNextWBase + baseTagGap + nextNameWBase
|
||||
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
|
||||
: 0;
|
||||
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
|
||||
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
|
||||
if (numLines === 0) return 0;
|
||||
|
||||
// Target width budget: cap at canvasW - 16 and reserve PAD_X
|
||||
// either side. If contentWBase exceeds the budget, scale the
|
||||
// font proportionally — clamped to 0.55 so labels stay legible
|
||||
// even on extreme split-panel widths.
|
||||
const maxBoxW = Math.max(40, canvasW - 16);
|
||||
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
|
||||
if (contentWBase > availContentW) {
|
||||
textScale = Math.max(0.55, availContentW / contentWBase);
|
||||
}
|
||||
|
||||
const lineH = Math.max(1, Math.round(baseLineH * textScale));
|
||||
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
|
||||
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
|
||||
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
|
||||
|
||||
// Phase-2 re-measurement at the scaled font sizes for the
|
||||
// final layout. measureText doesn't scale linearly with font
|
||||
// size on every glyph, so re-measuring is cheaper than
|
||||
// multiplying the base widths by textScale and risking a
|
||||
// half-pixel overflow.
|
||||
ctx.save();
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
const tagNowW = ctx.measureText(TAG_NOW).width;
|
||||
const tagNextW = ctx.measureText(TAG_NEXT).width;
|
||||
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
|
||||
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
|
||||
ctx.restore();
|
||||
|
||||
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
|
||||
const lineNextW = nextName
|
||||
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
|
||||
: 0;
|
||||
const contentW = Math.max(lineNowW, lineNextW);
|
||||
|
||||
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
|
||||
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
|
||||
|
||||
const E = Math.round(baseH * 0.25);
|
||||
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
||||
let bx, by;
|
||||
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
||||
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
||||
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
||||
else { bx = E; by = TOP_Y + stackOffset; }
|
||||
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
||||
by = Math.max(0, Math.min(canvasH - boxH, by));
|
||||
// Suppress overlap with the wrapped lyrics banner regardless
|
||||
// of corner. Bottom-corner cards on short panels can still
|
||||
// reach up into the banner once boxH exceeds the space below
|
||||
// the lyrics — same shape the chord diagram uses.
|
||||
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
||||
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// Layout each line with tag left-aligned, name in cyan after a
|
||||
// small gap. Both lines share the same x origin (bx + PAD_X)
|
||||
// so the tag column visually aligns vertically.
|
||||
const lineX = bx + PAD_X;
|
||||
let lineY = by + PAD_Y + lineH / 2;
|
||||
const TAG_COLOR = 'rgba(180,190,205,0.85)';
|
||||
const NAME_COLOR = '#00cccc';
|
||||
const TIME_COLOR = 'rgba(220,225,235,0.9)';
|
||||
|
||||
if (nowName) {
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
ctx.fillStyle = TAG_COLOR;
|
||||
ctx.fillText(TAG_NOW, lineX, lineY);
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
ctx.fillStyle = NAME_COLOR;
|
||||
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
|
||||
lineY += lineH;
|
||||
}
|
||||
if (nextName) {
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
ctx.fillStyle = TAG_COLOR;
|
||||
ctx.fillText(TAG_NEXT, lineX, lineY);
|
||||
const nextX = lineX + tagNextW + TAG_GAP;
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
ctx.fillStyle = NAME_COLOR;
|
||||
ctx.fillText(nextName, nextX, lineY);
|
||||
if (nextCountdown) {
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
ctx.fillStyle = TIME_COLOR;
|
||||
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
return boxH;
|
||||
}
|
||||
|
||||
// Tone-change HUD — card showing the active tone and the next upcoming
|
||||
// tone with a countdown. Mirrors drawSectionHud's layout contract
|
||||
// (position, size slider, lyricsBottom) but uses an amber accent colour
|
||||
// so it reads as distinct from the cyan section card.
|
||||
function drawToneHud(ctx, opts) {
|
||||
const {
|
||||
toneChanges, toneBase = '',
|
||||
currentTime,
|
||||
canvasW, canvasH,
|
||||
position = 'tl',
|
||||
sizeSlider = 0.5,
|
||||
lyricsBottom = 0,
|
||||
stackOffset = 0,
|
||||
} = opts;
|
||||
|
||||
// Resolve active tone: toneBase before all changes, else the most
|
||||
// recent change at or before currentTime.
|
||||
// toneChanges items use { t, name } (not { time, name }) — both
|
||||
// the legacy import path (server.py xml_tone_changes) and the sloppak
|
||||
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
|
||||
let curName = toneBase;
|
||||
let nextChange = null;
|
||||
if (toneChanges && toneChanges.length) {
|
||||
for (let i = 0; i < toneChanges.length; i++) {
|
||||
if (toneChanges[i].t <= currentTime) {
|
||||
curName = toneChanges[i].name;
|
||||
} else {
|
||||
nextChange = toneChanges[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!curName && !nextChange) return 0;
|
||||
|
||||
let nextName = '';
|
||||
let nextCountdown = '';
|
||||
if (nextChange) {
|
||||
const dt = nextChange.t - currentTime;
|
||||
nextName = nextChange.name;
|
||||
nextCountdown = dt > 10
|
||||
? 'in ' + Math.round(dt) + 's'
|
||||
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
|
||||
}
|
||||
|
||||
const sizeF = 0.65 + 0.85 * sizeSlider;
|
||||
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
|
||||
const PAD_X = Math.round(baseH * 0.45);
|
||||
const PAD_Y = Math.round(baseH * 0.20);
|
||||
let textScale = 1.0;
|
||||
const baseLineH = Math.round(baseH * 0.46);
|
||||
const baseNameSize = Math.round(baseH * 0.36);
|
||||
const baseTagSize = Math.round(baseH * 0.24);
|
||||
const baseTagGap = Math.round(baseH * 0.14);
|
||||
|
||||
const TAG_CUR = 'Tone:';
|
||||
const TAG_NEXT = 'Next:';
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `${baseTagSize}px sans-serif`;
|
||||
const tagCurWBase = ctx.measureText(TAG_CUR).width;
|
||||
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
|
||||
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||
ctx.font = `bold ${baseNameSize}px sans-serif`;
|
||||
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
|
||||
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
|
||||
ctx.restore();
|
||||
|
||||
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
|
||||
const lineNextWBase = nextName
|
||||
? tagNextWBase + baseTagGap + nextNameWBase
|
||||
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
|
||||
: 0;
|
||||
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
|
||||
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
|
||||
if (numLines === 0) return 0;
|
||||
|
||||
const maxBoxW = Math.max(40, canvasW - 16);
|
||||
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
|
||||
if (contentWBase > availContentW) {
|
||||
textScale = Math.max(0.55, availContentW / contentWBase);
|
||||
}
|
||||
|
||||
const lineH = Math.max(1, Math.round(baseLineH * textScale));
|
||||
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
|
||||
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
|
||||
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
const tagCurW = ctx.measureText(TAG_CUR).width;
|
||||
const tagNextW = ctx.measureText(TAG_NEXT).width;
|
||||
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
const curNameW = curName ? ctx.measureText(curName).width : 0;
|
||||
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
|
||||
ctx.restore();
|
||||
|
||||
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
|
||||
const lineNextW = nextName
|
||||
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
|
||||
: 0;
|
||||
const contentW = Math.max(lineCurW, lineNextW);
|
||||
|
||||
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
|
||||
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
|
||||
|
||||
const E = Math.round(baseH * 0.25);
|
||||
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
|
||||
let bx, by;
|
||||
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
|
||||
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
|
||||
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
|
||||
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
|
||||
bx = Math.max(0, Math.min(canvasW - boxW, bx));
|
||||
by = Math.max(0, Math.min(canvasH - boxH, by));
|
||||
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
|
||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
|
||||
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
const lineX = bx + PAD_X;
|
||||
let lineY = by + PAD_Y + lineH / 2;
|
||||
const TAG_COLOR = 'rgba(180,190,205,0.85)';
|
||||
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
|
||||
const TIME_COLOR = 'rgba(220,225,235,0.9)';
|
||||
|
||||
if (curName) {
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
ctx.fillStyle = TAG_COLOR;
|
||||
ctx.fillText(TAG_CUR, lineX, lineY);
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
ctx.fillStyle = NAME_COLOR;
|
||||
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
|
||||
lineY += lineH;
|
||||
}
|
||||
if (nextName) {
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
ctx.fillStyle = TAG_COLOR;
|
||||
ctx.fillText(TAG_NEXT, lineX, lineY);
|
||||
const nextX = lineX + tagNextW + TAG_GAP;
|
||||
ctx.font = `bold ${nameSize}px sans-serif`;
|
||||
ctx.fillStyle = NAME_COLOR;
|
||||
ctx.fillText(nextName, nextX, lineY);
|
||||
if (nextCountdown) {
|
||||
ctx.font = `${tagSize}px sans-serif`;
|
||||
ctx.fillStyle = TIME_COLOR;
|
||||
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
return boxH;
|
||||
}
|
||||
|
||||
function drawLyrics(lyrics, currentTime, ctx, W, H) {
|
||||
if (!lyrics._lines) {
|
||||
const lines = [];
|
||||
let line = null, word = null;
|
||||
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
|
||||
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
const l = lyrics[i];
|
||||
const raw = l.w || '';
|
||||
const endsLine = raw.endsWith('+');
|
||||
const continuesWord = raw.endsWith('-');
|
||||
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
|
||||
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
|
||||
if (!word) word = [];
|
||||
word.push(l);
|
||||
line.end = Math.max(line.end, l.t + l.d);
|
||||
if (!continuesWord) flushWord();
|
||||
if (endsLine) flushLine();
|
||||
}
|
||||
flushLine();
|
||||
lyrics._lines = lines;
|
||||
}
|
||||
const allLines = lyrics._lines;
|
||||
if (!allLines.length) return 0;
|
||||
|
||||
let currentIdx = -1;
|
||||
for (let i = 0; i < allLines.length; i++) {
|
||||
if (allLines[i].start <= currentTime) currentIdx = i;
|
||||
else break;
|
||||
}
|
||||
if (currentIdx === -1) {
|
||||
if (allLines[0].start - currentTime > 2.0) return 0;
|
||||
currentIdx = 0;
|
||||
}
|
||||
const currentLine = allLines[currentIdx];
|
||||
const nextLine = allLines[currentIdx + 1] || null;
|
||||
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
|
||||
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
|
||||
|
||||
const linesToShow = [currentLine];
|
||||
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
|
||||
|
||||
const fontSize = Math.max(18, H * 0.028) | 0;
|
||||
const lineY = H * 0.04;
|
||||
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
|
||||
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
let rows, spaceWidth, bgWidth;
|
||||
const _lc = _lyrRowsCache;
|
||||
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
|
||||
&& _lc.shown === linesToShow.length
|
||||
&& _lc.fontSize === fontSize && _lc.W === W) {
|
||||
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
|
||||
} else {
|
||||
spaceWidth = ctx.measureText(' ').width;
|
||||
const maxWidth = W * 0.8;
|
||||
|
||||
rows = [];
|
||||
for (const authoredLine of linesToShow) {
|
||||
let row = [], rowWidth = 0;
|
||||
for (const wordSyls of authoredLine.words) {
|
||||
const parts = [];
|
||||
let wordWidth = 0;
|
||||
for (const s of wordSyls) {
|
||||
const text = sylText(s);
|
||||
const w = ctx.measureText(text).width;
|
||||
parts.push({ syl: s, text, width: w });
|
||||
wordWidth += w;
|
||||
}
|
||||
const advance = wordWidth + spaceWidth;
|
||||
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
|
||||
row.push({ parts, advance });
|
||||
rowWidth += advance;
|
||||
}
|
||||
if (row.length) rows.push(row);
|
||||
}
|
||||
|
||||
bgWidth = 0;
|
||||
for (const row of rows) {
|
||||
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
if (rw > bgWidth) bgWidth = rw;
|
||||
}
|
||||
bgWidth = Math.min(bgWidth + 30, W * 0.85);
|
||||
_lyrRowsCache = {
|
||||
lyricsRef: lyrics, idx: currentIdx,
|
||||
shown: linesToShow.length, fontSize, W,
|
||||
rows, spaceWidth, bgWidth,
|
||||
};
|
||||
}
|
||||
|
||||
const rowHeight = fontSize + 6;
|
||||
const totalHeight = rows.length * rowHeight + 10;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.beginPath();
|
||||
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
|
||||
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
|
||||
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
|
||||
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
|
||||
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
|
||||
ctx.lineTo(bx + br, by + totalHeight);
|
||||
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
|
||||
ctx.lineTo(bx, by + br);
|
||||
ctx.quadraticCurveTo(bx, by, bx + br, by);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
|
||||
let xPos = W / 2 - rowWidth / 2;
|
||||
const yPos = lineY + r * rowHeight + 2;
|
||||
for (const w of row) {
|
||||
for (const part of w.parts) {
|
||||
const l = part.syl;
|
||||
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
|
||||
const isPast = currentTime >= l.t + l.d;
|
||||
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
|
||||
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
|
||||
ctx.fillText(part.text, xPos, yPos);
|
||||
xPos += part.width;
|
||||
}
|
||||
xPos += spaceWidth;
|
||||
}
|
||||
}
|
||||
// Return the actual bottom Y of the rendered background box so callers
|
||||
// (e.g. drawChordDiagram) can avoid overlapping it.
|
||||
return Math.round(by + totalHeight);
|
||||
}
|
||||
|
||||
return {
|
||||
drawChordDiagram,
|
||||
_drawDiagramCached,
|
||||
drawSectionHud,
|
||||
drawToneHud,
|
||||
drawLyrics,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
// h3d-carve-10: K-section (score FX) extracted from screen.js.
|
||||
// VERBATIM-MOVE: function bodies are byte-for-byte identical to their
|
||||
// screen.js originals except for 7 DI-rewires (see inline // DI: comments).
|
||||
// No logic changes, no new guards, no structural additions.
|
||||
//
|
||||
// Beyond-subst changes (all mechanical DI rewires):
|
||||
// 1. _fxSpawnPop: _ndFrameNowMs → getNdFrameNowMs()
|
||||
// 2. drawScoreFx: cam → aliased const cam = getCam()
|
||||
// 3. drawScoreFx: _probe → aliased const _probe = getProbe()
|
||||
// 4. drawScoreFx: _ndFrameNowMs → getNdFrameNowMs()
|
||||
// 5. drawScoreFx: nStr → getNStr()
|
||||
// 6. drawScoreFx: curX → getCurX()
|
||||
// 7. fxInit: highwayCanvas (closure) → getHighwayCanvas()
|
||||
|
||||
export function createScoreFx({ getHighwayCanvas, getNdFrameNowMs, getCam, getProbe, getNStr, getCurX, sY }) {
|
||||
|
||||
// ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ──
|
||||
// Two channels: (1) per-note "+N" score pops, sourced from the
|
||||
// note-state provider's new { points, mult, popKey } fields at the
|
||||
// moment a gem's verdict lands; (2) session-level bursts/pulses from
|
||||
// the new `notedetect:fx` event (streak milestones, multiplier tier
|
||||
// changes, streak breaks). Everything renders on the 2D overlay
|
||||
// canvas (same layer as drawNotedetectLabels) — no Three.js objects,
|
||||
// no txtMat() cache entries, nothing to dispose. Pools are fixed-
|
||||
// size slot arrays created once per factory instance; when all slots
|
||||
// are busy a new effect is simply dropped.
|
||||
const _FX_POP_LIFE_MS = 700;
|
||||
const _FX_BURST_LIFE_MS = 900;
|
||||
const _FX_BURST_N = 36;
|
||||
const _fxPops = Array.from({ length: 24 }, () => (
|
||||
{ active: false, x: 0, y: 0, z: 0, bornMs: 0, text: '', mult: 1 }
|
||||
));
|
||||
const _fxBursts = Array.from({ length: 4 }, () => ({
|
||||
active: false, bornMs: 0,
|
||||
px: new Float32Array(_FX_BURST_N), py: new Float32Array(_FX_BURST_N),
|
||||
vx: new Float32Array(_FX_BURST_N), vy: new Float32Array(_FX_BURST_N),
|
||||
}));
|
||||
// popKey -> expiry ms. Dedupes pops (chord members share the chord's
|
||||
// popKey; sustains keep returning points for the whole glow window).
|
||||
const _fxSeen = new Map();
|
||||
let _fxOnFx = null; // notedetect:fx listener (window)
|
||||
let _fxOnSkin = null; // notedetect:skin bus listener
|
||||
// Generation counter: bumped by teardown() so the deferred window-
|
||||
// copy fallback (a zero-delay task the listener removal can't cancel)
|
||||
// bails instead of re-arming ring/burst state after teardown — or,
|
||||
// worse, leaking a stale event into a subsequent init's fresh state.
|
||||
let _fxGen = 0;
|
||||
let _fxLastFxDetail = null; // reference dedup: window + instanceRoot dispatches share one detail
|
||||
// Details seen via element-scoped (bubbled) dispatch. A WeakSet, not a
|
||||
// single slot: one judged hit can emit several fx in the same task
|
||||
// (milestone + multiplier tier-up), and the deferred window-copy
|
||||
// fallback for the FIRST must still see that its element copy arrived
|
||||
// after the SECOND overwrote any last-detail slot. GC reclaims
|
||||
// entries once notedetect drops the detail objects.
|
||||
let _fxElemSeen = new WeakSet();
|
||||
let _fxRingMs = -1e9; // multiplier ring-pulse anchor
|
||||
let _fxRingMult = 1;
|
||||
let _fxBreakMs = -1e9; // streak-break flicker anchor
|
||||
// Canvas-side palette per notedetect skin (mirrors the accents in
|
||||
// notedetect's assets/plugin.css; fonts are document-loaded by that
|
||||
// stylesheet so the overlay canvas can use the family names).
|
||||
const _FX_PALETTES = {
|
||||
neon: { accent: '#00f0ff', accent2: '#ff2ec4', miss: '#ff4444', font: 'Orbitron' },
|
||||
esports: { accent: '#e8b43a', accent2: '#f5f5f4', miss: '#f87171', font: 'Rajdhani' },
|
||||
metal: { accent: '#ffb347', accent2: '#ff6b35', miss: '#ef4444', font: 'Russo One' },
|
||||
};
|
||||
let _fxPalette = _FX_PALETTES.neon;
|
||||
function _fxResolvePalette() {
|
||||
let skin = null;
|
||||
try { skin = localStorage.getItem('feedBack_notedetect_skin'); } catch (e) {}
|
||||
_fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon;
|
||||
}
|
||||
function _fxSpawnPop(popKey, points, mult, x, y, z) {
|
||||
if (_fxSeen.has(popKey)) return;
|
||||
const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs
|
||||
_fxSeen.set(popKey, nowMs + 4000);
|
||||
for (let i = 0; i < _fxPops.length; i++) {
|
||||
const p = _fxPops[i];
|
||||
if (p.active) continue;
|
||||
p.active = true;
|
||||
p.x = x; p.y = y; p.z = z;
|
||||
p.bornMs = nowMs;
|
||||
p.text = '+' + points;
|
||||
p.mult = mult || 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
function _fxSpawnBurst(nowMs) {
|
||||
for (let i = 0; i < _fxBursts.length; i++) {
|
||||
const b = _fxBursts[i];
|
||||
if (b.active) continue;
|
||||
b.active = true;
|
||||
b.bornMs = nowMs;
|
||||
for (let j = 0; j < _FX_BURST_N; j++) {
|
||||
const a = (j / _FX_BURST_N) * Math.PI * 2;
|
||||
const sp = 2 + (j % 5) * 0.8;
|
||||
b.px[j] = 0; b.py[j] = 0;
|
||||
b.vx[j] = Math.cos(a) * sp;
|
||||
b.vy[j] = Math.sin(a) * sp - 1.2;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
function _fxHandle(d) {
|
||||
// Reference dedup — notedetect dispatches the SAME detail object
|
||||
// on window and on its instanceRoot; whichever arrives first wins.
|
||||
if (d === _fxLastFxDetail) return;
|
||||
_fxLastFxDetail = d;
|
||||
const nowMs = performance.now();
|
||||
if (d.fxType === 'milestone') {
|
||||
_fxSpawnBurst(nowMs);
|
||||
} else if (d.fxType === 'multiplier' && d.mult > (d.prevMult || 1)) {
|
||||
_fxRingMs = nowMs;
|
||||
_fxRingMult = d.mult;
|
||||
} else if (d.fxType === 'streakBreak') {
|
||||
_fxBreakMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Score FX overlay pass — "+N" pops rising off their gems, milestone
|
||||
// particle bursts / multiplier ring-pulses / streak-break flickers
|
||||
// anchored on the strike line. Same overlay layer + projection
|
||||
// pattern as drawNotedetectLabels; costs one early-out when nothing
|
||||
// is active.
|
||||
function drawScoreFx(ctx, W, H) {
|
||||
const cam = getCam(); // DI: cam
|
||||
const _probe = getProbe(); // DI: _probe
|
||||
if (!cam || !_probe) return;
|
||||
const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs
|
||||
// TTL-prune the pop dedup keys (bounded: only notes hit in the
|
||||
// last few seconds).
|
||||
if (_fxSeen.size) {
|
||||
for (const [k, exp] of _fxSeen) {
|
||||
if (exp <= nowMs) _fxSeen.delete(k);
|
||||
}
|
||||
}
|
||||
let anyPop = false;
|
||||
for (let i = 0; i < _fxPops.length; i++) {
|
||||
if (_fxPops[i].active) { anyPop = true; break; }
|
||||
}
|
||||
let anyBurst = false;
|
||||
for (let i = 0; i < _fxBursts.length; i++) {
|
||||
if (_fxBursts[i].active) { anyBurst = true; break; }
|
||||
}
|
||||
const ringAge = nowMs - _fxRingMs;
|
||||
const breakAge = nowMs - _fxBreakMs;
|
||||
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) return;
|
||||
|
||||
const pal = _fxPalette;
|
||||
ctx.save();
|
||||
|
||||
// Streak-break flicker: brief red wash over the whole panel.
|
||||
if (breakAge < 350) {
|
||||
const a = 0.10 * (1 - breakAge / 350);
|
||||
ctx.fillStyle = pal.miss;
|
||||
ctx.globalAlpha = a;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Strike-line center in screen px — anchor for bursts + pulses.
|
||||
let cx = W / 2, cy = H * 0.72, centerOk = false;
|
||||
{
|
||||
const fretMidY = (sY(0) + sY(getNStr() - 1)) / 2; // DI: nStr
|
||||
_probe.set(getCurX(), fretMidY, 0); // DI: curX
|
||||
_probe.project(cam);
|
||||
if (_probe.z >= -1 && _probe.z <= 1) {
|
||||
cx = (_probe.x * 0.5 + 0.5) * W;
|
||||
cy = (-_probe.y * 0.5 + 0.5) * H;
|
||||
centerOk = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Multiplier ring-pulse: one expanding ring on tier-up; the ×4
|
||||
// tier pulses in the secondary accent like the HUD badge.
|
||||
if (centerOk && ringAge < 600) {
|
||||
const t = ringAge / 600;
|
||||
const ease = 1 - Math.pow(1 - t, 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = _fxRingMult >= 4 ? pal.accent2 : pal.accent;
|
||||
ctx.globalAlpha = 0.6 * (1 - t);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Milestone bursts.
|
||||
if (anyBurst && centerOk) {
|
||||
for (let i = 0; i < _fxBursts.length; i++) {
|
||||
const b = _fxBursts[i];
|
||||
if (!b.active) continue;
|
||||
const age = nowMs - b.bornMs;
|
||||
if (age >= _FX_BURST_LIFE_MS) { b.active = false; continue; }
|
||||
const t = age / _FX_BURST_LIFE_MS;
|
||||
ctx.globalAlpha = 1 - t;
|
||||
for (let j = 0; j < _FX_BURST_N; j++) {
|
||||
b.px[j] += b.vx[j];
|
||||
b.py[j] += b.vy[j];
|
||||
b.vy[j] += 0.08;
|
||||
ctx.fillStyle = (j & 1) ? pal.accent : pal.accent2;
|
||||
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// "+N" pops: rise off the gem and fade over the back half.
|
||||
if (anyPop) {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i < _fxPops.length; i++) {
|
||||
const p = _fxPops[i];
|
||||
if (!p.active) continue;
|
||||
const age = nowMs - p.bornMs;
|
||||
if (age >= _FX_POP_LIFE_MS) { p.active = false; continue; }
|
||||
_probe.set(p.x, p.y, p.z);
|
||||
_probe.project(cam);
|
||||
if (_probe.z < -1 || _probe.z > 1) continue;
|
||||
const t = age / _FX_POP_LIFE_MS;
|
||||
const sx = (_probe.x * 0.5 + 0.5) * W;
|
||||
const sy2 = (-_probe.y * 0.5 + 0.5) * H - t * 30;
|
||||
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
|
||||
ctx.font = `bold ${13 + (p.mult - 1) * 2}px '${pal.font}', sans-serif`;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
|
||||
ctx.strokeText(p.text, sx, sy2);
|
||||
ctx.fillStyle = pal.accent;
|
||||
ctx.fillText(p.text, sx, sy2);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
|
||||
// detail object twice in the same task: first explicitly on
|
||||
// window (unscoped), then as a bubbling CustomEvent from its
|
||||
// per-panel instanceRoot (scoped). Element-targeted copies are
|
||||
// authoritative — accept only the ones whose root lives in this
|
||||
// panel's container. The window copy is DEFERRED a task: by the
|
||||
// time it runs, the element copy (same detail reference) has
|
||||
// either arrived — making the window copy a duplicate to drop —
|
||||
// or it never will (detector root not attached to the DOM), in
|
||||
// which case the window copy is the compat fallback. This keeps
|
||||
// splitscreen panels from rendering each other's FX even for
|
||||
// the first event of a session.
|
||||
//
|
||||
// NOTE (verbatim-preserved): fxInit has NO re-entry guard —
|
||||
// the original screen.js init block had none. Calling fxInit()
|
||||
// twice double-registers the notedetect:fx listener. The dispatch
|
||||
// contract says to declare, not fix, this behavior here.
|
||||
function fxInit() {
|
||||
_fxResolvePalette();
|
||||
_fxOnFx = (e) => {
|
||||
const d = e && e.detail;
|
||||
if (!d) return;
|
||||
const t = e.target;
|
||||
if (t && t.parentElement) {
|
||||
_fxElemSeen.add(d);
|
||||
if (!getHighwayCanvas() || !t.parentElement.contains(getHighwayCanvas())) return; // DI: highwayCanvas
|
||||
_fxHandle(d);
|
||||
return;
|
||||
}
|
||||
const gen = _fxGen;
|
||||
setTimeout(() => {
|
||||
if (gen !== _fxGen) return; // torn down (or re-inited) meanwhile
|
||||
if (_fxElemSeen.has(d)) return;
|
||||
_fxHandle(d);
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener('notedetect:fx', _fxOnFx);
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function'
|
||||
&& typeof window.feedBack.off === 'function') {
|
||||
_fxOnSkin = () => _fxResolvePalette();
|
||||
window.feedBack.on('notedetect:skin', _fxOnSkin);
|
||||
}
|
||||
}
|
||||
|
||||
function fxTeardown() {
|
||||
if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; }
|
||||
if (window.feedBack && typeof window.feedBack.off === 'function') {
|
||||
if (_fxOnSkin) { try { window.feedBack.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; }
|
||||
}
|
||||
for (const p of _fxPops) p.active = false;
|
||||
for (const b of _fxBursts) b.active = false;
|
||||
_fxSeen.clear();
|
||||
_fxGen++; // invalidate any pending deferred window-copy fallbacks
|
||||
_fxLastFxDetail = null;
|
||||
_fxElemSeen = new WeakSet();
|
||||
_fxRingMs = _fxBreakMs = -1e9;
|
||||
}
|
||||
|
||||
function fxClearSeen() { _fxSeen.clear(); }
|
||||
return { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx, fxClearSeen };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// h3d-carve-11: R-section (string glow) extracted from screen.js.
|
||||
// VERBATIM-MOVE: updateStringHighlights body is byte-for-byte identical to
|
||||
// screen.js except for 7 DI-rewires at function entry (aliased locals).
|
||||
// No logic changes, no new guards.
|
||||
//
|
||||
// Beyond-subst changes (all mechanical DI rewires at function entry):
|
||||
// 1. glowMul → aliased: const glowMul = getGlowMul()
|
||||
// 2. _vibrancyIdleOp → aliased: const _vibrancyIdleOp = getVibrancyIdleOp()
|
||||
// 3. _venueSceneOverride → aliased: const _venueSceneOverride = getVenueSceneOverride()
|
||||
// 4. nStr → aliased: const nStr = getNStr()
|
||||
// 5. stringLines → aliased: const stringLines = getStringLines()
|
||||
// 6. mGlow → aliased: const mGlow = getMGlow()
|
||||
// 7. mAccentCore → aliased: const mAccentCore = getMAccentCore()
|
||||
// VENUE_GEM_EMISSIVE_MUL is a plain const shorthand (no aliasing needed).
|
||||
// Total DI params: 8 (1 plain const + 7 live getters, 0 setter pairs).
|
||||
|
||||
export function createStringGlow({
|
||||
VENUE_GEM_EMISSIVE_MUL,
|
||||
getGlowMul,
|
||||
getVibrancyIdleOp,
|
||||
getVenueSceneOverride,
|
||||
getNStr,
|
||||
getStringLines,
|
||||
getMGlow,
|
||||
getMAccentCore,
|
||||
}) {
|
||||
function updateStringHighlights(noteState) {
|
||||
// DI: all mutable IIFE-scope vars aliased here; body is verbatim.
|
||||
const glowMul = getGlowMul(); // DI: glowMul
|
||||
const _vibrancyIdleOp = getVibrancyIdleOp(); // DI: _vibrancyIdleOp
|
||||
const _venueSceneOverride = getVenueSceneOverride(); // DI: _venueSceneOverride
|
||||
const nStr = getNStr(); // DI: nStr
|
||||
const stringLines = getStringLines(); // DI: stringLines
|
||||
const mGlow = getMGlow(); // DI: mGlow
|
||||
const mAccentCore = getMAccentCore(); // DI: mAccentCore
|
||||
|
||||
// Glow slider scales both the idle floor and anticipation peak,
|
||||
// so glowMul=0 fully silences the per-string emissive pulse.
|
||||
// Vibrancy controls the idle opacity floor — anticipation
|
||||
// still rides on top regardless of vibrancy so play-feedback
|
||||
// through the opacity channel survives even at glowMul=0.
|
||||
//
|
||||
// Folded with the post-noteState mGlow / mAccentCore writes
|
||||
// (was a separate `for (s = 0; s < nStr)` loop in update()),
|
||||
// so the per-string scratch arrays stay hot in L1 across all
|
||||
// material writes for a given string.
|
||||
const BASE_GLOW = 0.02 * glowMul;
|
||||
const MAX_GLOW = 3.5 * glowMul;
|
||||
const IDLE_OP = _vibrancyIdleOp;
|
||||
const g = glowMul;
|
||||
const venueGemMul = _venueSceneOverride ? VENUE_GEM_EMISSIVE_MUL : 1;
|
||||
|
||||
for (let s = 0; s < nStr; s++) {
|
||||
const mesh = stringLines[s];
|
||||
if (mesh) {
|
||||
const intensity = Math.max(
|
||||
noteState.stringSustain[s] ? 1 : 0,
|
||||
noteState.stringAnticipation[s] || 0,
|
||||
);
|
||||
mesh.material.emissiveIntensity = BASE_GLOW + intensity * MAX_GLOW;
|
||||
mesh.material.opacity = IDLE_OP + intensity * (1 - IDLE_OP);
|
||||
mesh.scale.set(1, 1 + intensity * 0.3, 1 + intensity * 0.3);
|
||||
}
|
||||
// Hit-note emissive — same write pattern as the standalone
|
||||
// loop that previously lived at update()'s post-call site.
|
||||
// The glow slider scales it here since this assignment
|
||||
// stomps anything _applyGlow() set statically.
|
||||
const bg = noteState.strGlow[s] * g;
|
||||
if (mGlow[s]) mGlow[s].emissiveIntensity = bg * venueGemMul;
|
||||
if (mAccentCore[s]) {
|
||||
mAccentCore[s].emissiveIntensity =
|
||||
(bg + noteState.accentFillBoost[s] * g) * venueGemMul;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { updateStringHighlights };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Three.js lazy loader — h3d-carve-2.
|
||||
*
|
||||
* Memoised singleton: the first `loadThree()` call kicks off the import;
|
||||
* every subsequent call returns the same promise. `T` is a live-binding
|
||||
* export so the IIFE in screen.js sees the updated reference after the
|
||||
* promise resolves without any explicit getter call.
|
||||
*
|
||||
* Falls back to jsdelivr CDN when the local vendor copy is unavailable
|
||||
* (air-gapped / static-file layout mismatch / dev origin).
|
||||
*/
|
||||
|
||||
// ── URL constants (mirrors screen.js A-section; never vary at runtime) ──────
|
||||
|
||||
const THREE_URL = '/static/vendor/three/three.module.min.js';
|
||||
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';
|
||||
|
||||
// ── Memoised loader ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Live binding — updated to the Three.js module namespace on first load. */
|
||||
export let T = null;
|
||||
|
||||
let threeLoadPromise = null;
|
||||
|
||||
export function loadThree() {
|
||||
if (!threeLoadPromise) {
|
||||
threeLoadPromise = import(THREE_URL)
|
||||
.then(mod => { T = mod; return mod; })
|
||||
.catch(() => import(THREE_CDN)
|
||||
.then(mod => { T = mod; return mod; })
|
||||
.catch(e => {
|
||||
console.error('[3D-Hwy] Three.js load failed:', e);
|
||||
threeLoadPromise = null;
|
||||
throw e;
|
||||
}));
|
||||
}
|
||||
return threeLoadPromise;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Color utilities, tuning helpers, and splitscreen predicates — h3d-carve-3.
|
||||
*
|
||||
* All exports are pure functions or compile-time constants; none capture
|
||||
* factory-scope state. NSTR and MAX_RENDER_STRINGS are compile-time copies
|
||||
* of the matching IIFE constants (both = 6 for the current 6-entry
|
||||
* PALETTES.default / S_COL layout).
|
||||
*/
|
||||
|
||||
// ── Compile-time IIFE constant ────────────────────────────────────────────────
|
||||
// NSTR: standard guitar default (not a palette ceiling — safe as a fixed fact).
|
||||
// MAX_RENDER_STRINGS is NOT duplicated here; it is the authority of S_COL.length
|
||||
// in screen.js and flows in via the maxStrings parameter of resolveStringCount
|
||||
// and _openStringPitchLabelsForTuning. screen.js keeps 1-line delegators that
|
||||
// supply MAX_RENDER_STRINGS so zero call-sites change.
|
||||
const NSTR = 6;
|
||||
|
||||
// ── Color utilities ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a CSS hex color string ('#rrggbb', '#rgb', or bare variants) to a
|
||||
* packed 0xRRGGBB integer. Returns null on any parse failure.
|
||||
*/
|
||||
export function _h3dHexToInt(hex) {
|
||||
if (typeof hex !== 'string') return null;
|
||||
const t = hex.trim().replace(/^#/, '');
|
||||
const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
||||
return parseInt(full, 16);
|
||||
}
|
||||
|
||||
export function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); }
|
||||
|
||||
export function _darkenInt(hex, factor) {
|
||||
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
|
||||
return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor);
|
||||
}
|
||||
|
||||
export function _lightenInt(hex, t) {
|
||||
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
|
||||
return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t);
|
||||
}
|
||||
|
||||
// ── String-count resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the string count for the active arrangement. Prefer
|
||||
* bundle.stringCount (exposed by feedBack core since #93 — derived from
|
||||
* notes/chords/tuning, works for 5-string bass, 7- and 8-string guitar).
|
||||
* Falls back to arrangement-name detection for older feedBack cores.
|
||||
* Clamped to MAX_RENDER_STRINGS so a malformed bundle doesn't index past
|
||||
* the per-string material arrays.
|
||||
*/
|
||||
export function resolveStringCount(bundle, maxStrings) {
|
||||
const sc = bundle && bundle.stringCount;
|
||||
if (Number.isFinite(sc) && sc >= 1) {
|
||||
return Math.min(Math.trunc(sc), maxStrings);
|
||||
}
|
||||
return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR;
|
||||
}
|
||||
|
||||
// ── Tuning / pitch-label helpers ──────────────────────────────────────────────
|
||||
|
||||
/** Chart-format tuning entries are semitone offsets from instrument standard. */
|
||||
export const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
// Open-string MIDI (thick → thin), matched to RS string index 0 low.
|
||||
export const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]);
|
||||
export const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]);
|
||||
export const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]);
|
||||
export const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]);
|
||||
// F#/B/E standard extension — low string is a fifth below RS 7-string low B.
|
||||
export const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]);
|
||||
|
||||
export function _baseOpenStringMidis(sc, arrangement) {
|
||||
const isBass = /bass/i.test(arrangement || '');
|
||||
if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice();
|
||||
if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4);
|
||||
if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice();
|
||||
if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5);
|
||||
if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice();
|
||||
if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice();
|
||||
if (Number.isFinite(sc) && sc > 8) {
|
||||
const out = Array.from(_BASE_OPEN_MIDI_GUITAR8);
|
||||
let last = out[out.length - 1];
|
||||
while (out.length < sc) {
|
||||
last += 5;
|
||||
out.push(last);
|
||||
}
|
||||
return out.slice(0, sc);
|
||||
}
|
||||
const g6 = _BASE_OPEN_MIDI_GUITAR6.slice();
|
||||
if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc);
|
||||
return g6;
|
||||
}
|
||||
|
||||
export function _midiToPitchLabel(midi) {
|
||||
const m = Math.round(midi);
|
||||
const octave = Math.floor(m / 12) - 1;
|
||||
const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12];
|
||||
return n + octave;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} bundle Highway render bundle (tuning, capo, stringCount)
|
||||
* @param {object} songInfo WS song_info blob (arrangement, tuning, capo)
|
||||
* @param {number} nEffective String count clamped like nStr / resolveStringCount
|
||||
*/
|
||||
export function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective, maxStrings) {
|
||||
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), maxStrings) : resolveStringCount(bundle, maxStrings);
|
||||
// bundle first: chart-transform substitutes tuning/capo there, while
|
||||
// songInfo keeps the chart's originals by contract. A malformed
|
||||
// (non-array) bundle.tuning falls back to songInfo instead of
|
||||
// blanking the labels.
|
||||
let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning);
|
||||
let cap = bundle.capo;
|
||||
cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0);
|
||||
if (!Array.isArray(tuning)) tuning = [];
|
||||
|
||||
const base = _baseOpenStringMidis(n, songInfo?.arrangement);
|
||||
const labels = [];
|
||||
for (let s = 0; s < n; s++) {
|
||||
const offRaw = tuning[s];
|
||||
const off = Number.isFinite(offRaw) ? offRaw : 0;
|
||||
const midi = (base[s] !== undefined ? base[s] : 40) + off + cap;
|
||||
labels.push(_midiToPitchLabel(midi));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ── Splitscreen predicates ────────────────────────────────────────────────────
|
||||
// window.feedBackSplitscreen is read live each call — never captured at
|
||||
// module or factory scope.
|
||||
|
||||
export function _ssActive() {
|
||||
const ss = window.feedBackSplitscreen;
|
||||
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
|
||||
return typeof ss.isCanvasFocused === 'function'
|
||||
&& typeof ss.onFocusChange === 'function'
|
||||
&& typeof ss.offFocusChange === 'function';
|
||||
}
|
||||
|
||||
export function _ssIsCanvasFocused(highwayCanvas) {
|
||||
const ss = window.feedBackSplitscreen;
|
||||
if (!_ssActive()) return true;
|
||||
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
|
||||
ss.isCanvasFocused(highwayCanvas));
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
// Player-chrome background control.
|
||||
//
|
||||
// The control mounts a Background picker (style / Reactive / Intensity) into
|
||||
// the player's Plugin Controls popover so the background can be changed
|
||||
// mid-song. Two things about it are easy to get wrong and invisible when they
|
||||
// are:
|
||||
//
|
||||
// * It is REFCOUNTED. Several renderer instances can be live at once (a
|
||||
// splitscreen host creates one per panel), but the settings it writes are
|
||||
// global — N controls would be N ways to set one value, and a leaked
|
||||
// refcount pins a dead control in the UI. The multi-instance behaviour is
|
||||
// exercised here with stubbed instances; it is NOT verified against a real
|
||||
// splitscreen session, whose visualizer does not currently work.
|
||||
// * It GREYS OUT controls the active style ignores. Not every background
|
||||
// style reads `intensity`, and none of them read audio bands under
|
||||
// Butterchurn, so a live-looking knob that does nothing is a real bug.
|
||||
//
|
||||
// h3d-carve-5: the _pc* block was moved from screen.js to src/bg-control.js.
|
||||
// load() now evaluates the factory module (stripping the `export` keyword),
|
||||
// calls createBgControl({DI}) with stubbed deps, and injects test-only getters
|
||||
// into the return object so the private state vars remain observable.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
|
||||
const BG_CONTROL_JS = path.join(__dirname, '..', 'src', 'bg-control.js');
|
||||
|
||||
// What each style is expected to consume, derived by reading the BG_STYLES
|
||||
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
|
||||
// table, which would only assert that the table equals itself.
|
||||
// intensity: true => the style's build() reads settings.intensity
|
||||
// reactive: true => the style's update() dereferences its `bands` argument
|
||||
// 'butterchurn' is a mode, not a BG_STYLES fog-scenery entry: _bcSyncMode
|
||||
// owns its controller and drives its own audio tap + canvas opacity (only
|
||||
// the fog-scenery half falls through to BG_STYLES.off), so both are false.
|
||||
const EXPECTED_USES = {
|
||||
off: { intensity: false, reactive: false },
|
||||
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 },
|
||||
video: { intensity: false, reactive: false },
|
||||
butterchurn: { intensity: false, reactive: false },
|
||||
};
|
||||
|
||||
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video'];
|
||||
|
||||
// Minimal DOM: only what the control touches.
|
||||
function makeDom() {
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag).toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.listeners = {};
|
||||
this.style = { cssText: '' };
|
||||
this.disabled = false;
|
||||
this._on = false;
|
||||
}
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
||||
removeChild(c) {
|
||||
const i = this.children.indexOf(c);
|
||||
if (i >= 0) this.children.splice(i, 1);
|
||||
c.parentNode = null;
|
||||
return c;
|
||||
}
|
||||
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
|
||||
setAttribute(k, v) { this[k] = v; }
|
||||
removeAttribute(k) { delete this[k]; }
|
||||
get isConnected() {
|
||||
let n = this;
|
||||
while (n.parentNode) n = n.parentNode;
|
||||
return n === root;
|
||||
}
|
||||
querySelector(sel) {
|
||||
const m = /^option\[value="(.+)"\]$/.exec(sel);
|
||||
const want = m ? m[1] : null;
|
||||
const walk = (n) => {
|
||||
for (const c of n.children) {
|
||||
if (want != null && c.tagName === 'OPTION' && c.value === want) return c;
|
||||
const r = walk(c);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(this);
|
||||
}
|
||||
fire(type) { (this.listeners[type] || []).forEach((fn) => fn()); }
|
||||
}
|
||||
const root = new El('root');
|
||||
const slot = new El('div');
|
||||
root.appendChild(slot);
|
||||
return { El, root, slot };
|
||||
}
|
||||
|
||||
function load({ store: initialStore } = {}) {
|
||||
// h3d-carve-5: load from src/bg-control.js (factory module) instead of
|
||||
// slicing screen.js. Strip `export` for vm eval; inject test-only getters
|
||||
// into the return object so private _pc* state vars remain observable.
|
||||
const bgSrc = fs.readFileSync(BG_CONTROL_JS, 'utf8');
|
||||
const stripped = bgSrc.replace(/^export\s+/gm, '');
|
||||
// Augment the factory's return with accessor getters for private state so
|
||||
// all existing test assertions (api.el, api.sel, api.refs, ...) keep working.
|
||||
const instrumented = stripped.replace(
|
||||
/return\s*\{\s*_pcAcquire\s*,\s*_pcRelease\s*\}/,
|
||||
'return { _pcAcquire, _pcRelease,'
|
||||
+ ' get el() { return _pcEl; },'
|
||||
+ ' get sel() { return _pcSel; },'
|
||||
+ ' get react() { return _pcReactive; },'
|
||||
+ ' get intens() { return _pcIntensity; },'
|
||||
+ ' get reason() { return _pcReason; },'
|
||||
+ ' get refs() { return _pcRefs; } }',
|
||||
);
|
||||
assert.notEqual(instrumented, stripped, 'return-augmentation anchor not found in bg-control.js');
|
||||
|
||||
const dom = makeDom();
|
||||
const store = Object.assign({
|
||||
style: 'particles',
|
||||
reactive: true,
|
||||
intensity: 0.5,
|
||||
customImageDataUrl: '',
|
||||
customVideoName: '',
|
||||
}, initialStore);
|
||||
|
||||
const bus = {};
|
||||
const listeners = new Set();
|
||||
const emit = (key) => { for (const fn of listeners) fn(key); };
|
||||
const writes = [];
|
||||
const timers = [];
|
||||
|
||||
// DI dependencies as vm-globals. Tests mutate sandbox._venueSceneOverride
|
||||
// directly; getVenueSceneOverride in the factory call reads it via the vm global.
|
||||
const sandbox = {
|
||||
console,
|
||||
BG_STYLE_IDS,
|
||||
_venueSceneOverride: false,
|
||||
_bgReadGlobal: (key) => store[key],
|
||||
_bgSubscribe: (fn) => listeners.add(fn),
|
||||
_bgUnsubscribe: (fn) => listeners.delete(fn),
|
||||
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
||||
clearTimeout: () => {},
|
||||
document: {
|
||||
createElement: (t) => new dom.El(t),
|
||||
// The Settings-panel mirror looks these up; absent here so it no-ops.
|
||||
getElementById: () => null,
|
||||
},
|
||||
window: {
|
||||
feedBack: {
|
||||
uiVersion: 'v3', // _pcSlot gates on this (docs/plugin-v3-ui.md)
|
||||
ui: { playerControlSlot: () => dom.slot },
|
||||
// The real bus is an EventTarget wrapper exposing on/off. Modelled
|
||||
// here so the screen:changed subscription — and its removal — are
|
||||
// observable.
|
||||
on: (ev, fn) => { (bus[ev] || (bus[ev] = [])).push(fn); },
|
||||
off: (ev, fn) => {
|
||||
const l = bus[ev];
|
||||
if (!l) return;
|
||||
const i = l.indexOf(fn);
|
||||
if (i >= 0) l.splice(i, 1);
|
||||
},
|
||||
},
|
||||
h3dBgSetStyle: (v) => { writes.push(['style', v]); store.style = v; emit('style'); },
|
||||
h3dBgSetReactive: (v) => { writes.push(['reactive', v]); store.reactive = v; emit('reactive'); },
|
||||
h3dBgSetIntensity: (v) => { writes.push(['intensity', v]); store.intensity = v; emit('intensity'); },
|
||||
},
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
|
||||
// Step 1: define createBgControl in the vm context.
|
||||
vm.runInContext(instrumented, sandbox);
|
||||
|
||||
// Step 2: call the factory; DI values are vm-globals so the call names them directly.
|
||||
const api = vm.runInContext(
|
||||
'createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,'
|
||||
+ ' getVenueSceneOverride: () => _venueSceneOverride })',
|
||||
sandbox,
|
||||
);
|
||||
|
||||
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
|
||||
const screenHooks = () => (bus['screen:changed'] || []).length;
|
||||
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
|
||||
}
|
||||
|
||||
// Slice the real _bgReadSetting + _bgReadGlobal out of screen.js and run them
|
||||
// against a localStorage stub. The main suite stubs both helpers identically,
|
||||
// so it can't tell the #2 refactor from a no-op; this one proves the actual
|
||||
// helper bodies differ where they must: _bgReadGlobal ignores a per-panel
|
||||
// override that _bgReadSetting(panelKey, ...) still honours.
|
||||
test('_bgReadGlobal reads the global slot, ignoring per-panel overrides', () => {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const rgStart = src.indexOf(' function _bgReadSetting(panelKey, key) {');
|
||||
const rgEnd = src.indexOf(' // Shared "stored string -> bool" coercion');
|
||||
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'could not slice the read helpers');
|
||||
const block = src.slice(rgStart, rgEnd);
|
||||
|
||||
const storage = new Map();
|
||||
const sandbox = {
|
||||
localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null) },
|
||||
_bgCoerce: (_key, v) => v, // identity: we test key resolution, not coercion
|
||||
_bgMemFallback: Object.create(null),
|
||||
BG_DEFAULTS: { style: 'particles' },
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
const api = vm.runInNewContext(block + '\n({ _bgReadSetting, _bgReadGlobal, _bgMemFallback })', sandbox);
|
||||
|
||||
storage.set('h3d_bg_style', 'lights'); // global
|
||||
storage.set('h3d_bg_panel3_style', 'geometric'); // a per-panel override
|
||||
|
||||
// The renderer, reading with a panel key, honours the per-panel override...
|
||||
assert.equal(api._bgReadSetting('panel3', 'style'), 'geometric');
|
||||
// ...but the shared control's global read must NOT see it - this is the
|
||||
// whole point of #2 (previously _bgReadSetting(null, ...) relied on
|
||||
// 'h3d_bg_null_style' never existing).
|
||||
assert.equal(api._bgReadGlobal('style'), 'lights');
|
||||
|
||||
// In-memory staged value wins over the persisted global (matches
|
||||
// _bgReadSetting's precedence).
|
||||
api._bgMemFallback.style = 'aurora';
|
||||
assert.equal(api._bgReadGlobal('style'), 'aurora');
|
||||
delete api._bgMemFallback.style;
|
||||
|
||||
// Nothing stored -> BG_DEFAULTS.
|
||||
assert.equal(api._bgReadGlobal('style'), 'lights');
|
||||
storage.delete('h3d_bg_style');
|
||||
assert.equal(api._bgReadGlobal('style'), 'particles');
|
||||
});
|
||||
|
||||
test('mounts one control into the player-control slot', () => {
|
||||
const { api, dom } = load();
|
||||
api._pcAcquire();
|
||||
assert.equal(dom.slot.children.length, 1);
|
||||
assert.ok(api.sel, 'style dropdown was not created');
|
||||
assert.equal(api.sel.children.length, BG_STYLE_IDS.length, 'one option per style');
|
||||
});
|
||||
|
||||
test('multiple renderer instances share a single control', () => {
|
||||
const { api, dom } = load();
|
||||
api._pcAcquire();
|
||||
api._pcAcquire();
|
||||
api._pcAcquire();
|
||||
api._pcAcquire();
|
||||
assert.equal(dom.slot.children.length, 1, 'four instances must not mount four controls');
|
||||
assert.equal(api.refs, 4);
|
||||
|
||||
api._pcRelease();
|
||||
api._pcRelease();
|
||||
api._pcRelease();
|
||||
assert.equal(dom.slot.children.length, 1, 'still held by the last instance');
|
||||
api._pcRelease();
|
||||
assert.equal(dom.slot.children.length, 0, 'last release must unmount');
|
||||
assert.equal(api.el, null);
|
||||
});
|
||||
|
||||
test('binds the screen hook on a retry when the bus was not ready at acquire', () => {
|
||||
const ctl = load();
|
||||
// Cold load: on a fresh page the renderer can init before the event bus is
|
||||
// wired AND before the rail popover exists. Simulate both being absent.
|
||||
const savedOn = ctl.sandbox.window.feedBack.on;
|
||||
const savedUi = ctl.sandbox.window.feedBack.ui;
|
||||
delete ctl.sandbox.window.feedBack.on;
|
||||
ctl.sandbox.window.feedBack.ui = {}; // no playerControlSlot -> mount fails
|
||||
|
||||
ctl.api._pcAcquire();
|
||||
assert.equal(ctl.screenHooks(), 0, 'nothing to bind to yet');
|
||||
assert.equal(ctl.api.el, null, 'no slot yet, so nothing mounted');
|
||||
|
||||
// Bus + slot come online; the retry tick must bind the hook, not only mount.
|
||||
ctl.sandbox.window.feedBack.on = savedOn;
|
||||
ctl.sandbox.window.feedBack.ui = savedUi;
|
||||
ctl.timers.shift()(); // run one retry tick
|
||||
|
||||
assert.equal(ctl.screenHooks(), 1, 'the retry tick failed to bind the screen hook');
|
||||
assert.ok(ctl.api.el, 'and it should have mounted too');
|
||||
ctl.api._pcRelease();
|
||||
});
|
||||
|
||||
test('the last release unbinds the screen:changed hook', () => {
|
||||
const ctl = load();
|
||||
ctl.api._pcAcquire();
|
||||
assert.equal(ctl.screenHooks(), 1, 'acquire should subscribe once');
|
||||
|
||||
ctl.api._pcAcquire();
|
||||
ctl.api._pcRelease();
|
||||
assert.equal(ctl.screenHooks(), 1, 'a partial release must keep the hook');
|
||||
|
||||
ctl.api._pcRelease();
|
||||
assert.equal(ctl.screenHooks(), 0, 'the hook outlived the control');
|
||||
|
||||
// And re-acquiring must re-subscribe exactly once, not zero times (the
|
||||
// bind is guarded on _pcScreenHook, so failing to null it would leave the
|
||||
// control permanently deaf to chrome rebuilds).
|
||||
ctl.api._pcAcquire();
|
||||
assert.equal(ctl.screenHooks(), 1, 're-acquire did not re-subscribe');
|
||||
ctl.api._pcRelease();
|
||||
});
|
||||
|
||||
test('teardown unsubscribes from the settings bus', () => {
|
||||
const ctl = load();
|
||||
ctl.api._pcAcquire();
|
||||
assert.equal(ctl.listenerCount(), 1);
|
||||
ctl.api._pcRelease();
|
||||
assert.equal(ctl.listenerCount(), 0, 'listener leaked after unmount');
|
||||
});
|
||||
|
||||
test('tracks changes made from the Settings page', () => {
|
||||
const { api, store, emit } = load();
|
||||
api._pcAcquire();
|
||||
store.style = 'lights';
|
||||
emit('style');
|
||||
assert.equal(api.sel.value, 'lights');
|
||||
});
|
||||
|
||||
test('custom media options stay disabled until something is uploaded', () => {
|
||||
const { api, store, emit } = load();
|
||||
api._pcAcquire();
|
||||
assert.equal(api.sel.querySelector('option[value="image"]').disabled, true);
|
||||
store.customImageDataUrl = 'data:image/png;base64,AAAA';
|
||||
emit('customImageDataUrl');
|
||||
assert.equal(api.sel.querySelector('option[value="image"]').disabled, false);
|
||||
assert.equal(api.sel.querySelector('option[value="video"]').disabled, true, 'video is independent');
|
||||
});
|
||||
|
||||
test('re-mounts into a fresh slot when the player chrome is rebuilt', () => {
|
||||
const { api, dom, sandbox, listenerCount } = load();
|
||||
api._pcAcquire();
|
||||
const first = api.el;
|
||||
|
||||
dom.root.removeChild(dom.slot);
|
||||
const fresh = new dom.El('div');
|
||||
dom.root.appendChild(fresh);
|
||||
sandbox.window.feedBack.ui.playerControlSlot = () => fresh;
|
||||
|
||||
api._pcAcquire();
|
||||
assert.equal(fresh.children.length, 1, 'did not remount into the new slot');
|
||||
assert.notEqual(api.el, first, 'stale node was reused');
|
||||
assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
|
||||
});
|
||||
|
||||
test('a non-v3 host mounts nothing (uiVersion gate)', () => {
|
||||
const ctl = load();
|
||||
ctl.sandbox.window.feedBack.uiVersion = 'v2'; // pre-v3 shell
|
||||
ctl.api._pcAcquire();
|
||||
assert.equal(ctl.api.el, null, 'must not mount when uiVersion is not v3');
|
||||
assert.equal(ctl.dom.slot.children.length, 0);
|
||||
// A non-v3 shell has no slot and never will, so no retry should be scheduled
|
||||
// at all — the loop is for a not-yet-built v3 slot, not for polling v2.
|
||||
assert.equal(ctl.timers.length, 0, 'a non-v3 host must not schedule the retry loop');
|
||||
ctl.api._pcRelease();
|
||||
});
|
||||
|
||||
test('a host with no player-control slot mounts nothing and does not throw', () => {
|
||||
const { api, dom, sandbox, timers } = load();
|
||||
sandbox.window.feedBack.ui = {};
|
||||
api._pcAcquire();
|
||||
assert.equal(api.el, null);
|
||||
assert.equal(dom.slot.children.length, 0);
|
||||
|
||||
let guard = 0;
|
||||
while (timers.length && guard++ < 100) timers.shift()();
|
||||
assert.ok(guard < 100, 'retry loop did not terminate');
|
||||
});
|
||||
|
||||
test('intensity writes once on release, not on every drag step', () => {
|
||||
const { api, writes } = load();
|
||||
api._pcAcquire();
|
||||
for (const v of ['0.10', '0.20', '0.30', '0.40', '0.50']) {
|
||||
api.intens.value = v;
|
||||
api.intens.fire('input');
|
||||
}
|
||||
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 0,
|
||||
'dragging must not write — every write rebuilds the background scene');
|
||||
api.intens.fire('change');
|
||||
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 1,
|
||||
'releasing must write exactly once');
|
||||
});
|
||||
|
||||
test('the dropdown and Reactive pill drive the real setters', () => {
|
||||
const { api, store, writes } = load();
|
||||
api._pcAcquire();
|
||||
api.sel.value = 'geometric';
|
||||
api.sel.fire('change');
|
||||
assert.equal(store.style, 'geometric');
|
||||
|
||||
const before = store.reactive;
|
||||
api.react.fire('click');
|
||||
assert.equal(store.reactive, !before, 'Reactive pill must toggle');
|
||||
assert.ok(writes.some((w) => w[0] === 'reactive'));
|
||||
});
|
||||
|
||||
test('exposes state and reasons to assistive tech', () => {
|
||||
const ctl = load({ store: { style: 'image', reactive: true } }); // image: reactive inert
|
||||
ctl.api._pcAcquire();
|
||||
|
||||
// The reason live-region must be a REAL mounted element with the id the
|
||||
// controls reference - not a dangling pointer. Assert resolution, not a
|
||||
// literal (a wrong id in code would still equal the literal).
|
||||
const reason = ctl.api.reason;
|
||||
assert.ok(reason, 'the reason span was not created');
|
||||
assert.equal(reason.id, 'h3d-pc-reason');
|
||||
assert.equal(reason.parentNode, ctl.api.el, 'the reason span must be mounted in the control');
|
||||
|
||||
// aria-pressed: a toggle button must expose its state. image greys
|
||||
// Reactive, so not-pressed AND disabled, and it points at the reason.
|
||||
assert.equal(ctl.api.react['aria-pressed'], 'false', 'greyed toggle is not pressed');
|
||||
assert.equal(ctl.api.react['aria-disabled'], 'true');
|
||||
// Pointer must resolve to the actual span's id (kills a wrong-id mutation),
|
||||
// and the span must carry the current reason text (kills a never-set-text
|
||||
// mutation).
|
||||
assert.equal(ctl.api.react['aria-describedby'], reason.id, 'inert control must reference the reason span');
|
||||
assert.equal(reason.textContent, 'This background does not react to audio', 'reason text must match the style');
|
||||
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'an ENABLED control carries no reason');
|
||||
|
||||
// The intensity describe path: a style where INTENSITY is inert.
|
||||
ctl.store.style = 'video'; ctl.emit('style');
|
||||
assert.equal(ctl.api.intens.disabled, true, 'precondition: video greys intensity');
|
||||
assert.equal(ctl.api.intens['aria-describedby'], reason.id, 'inert intensity must reference the reason');
|
||||
assert.equal(reason.textContent, 'The video plays as-is - nothing to adjust here');
|
||||
|
||||
// Both enabled: describedby drops, aria-pressed follows the value.
|
||||
ctl.store.style = 'particles'; ctl.store.reactive = true; ctl.emit('style');
|
||||
assert.equal(ctl.api.react['aria-describedby'], undefined, 'enabled control drops the reason');
|
||||
assert.equal(ctl.api.intens['aria-describedby'], undefined);
|
||||
assert.equal(ctl.api.react['aria-pressed'], 'true', 'reactive on for particles');
|
||||
ctl.store.reactive = false; ctl.emit('reactive');
|
||||
assert.equal(ctl.api.react['aria-pressed'], 'false', 'aria-pressed follows the value');
|
||||
|
||||
// Accessible names on the non-label controls.
|
||||
assert.equal(ctl.api.sel['aria-label'], 'Background style');
|
||||
assert.equal(ctl.api.intens['aria-label'], 'Background intensity');
|
||||
ctl.api._pcRelease();
|
||||
});
|
||||
|
||||
test('greys out exactly the controls each style ignores', () => {
|
||||
const { api, store, emit } = load();
|
||||
api._pcAcquire();
|
||||
for (const [style, want] of Object.entries(EXPECTED_USES)) {
|
||||
store.style = style;
|
||||
emit('style');
|
||||
assert.equal(!api.intens.disabled, want.intensity, `${style}: intensity enabled-ness`);
|
||||
assert.equal(!api.react.disabled, want.reactive, `${style}: reactive enabled-ness`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Venue override greys the whole Background group', () => {
|
||||
const ctl = load({ store: { style: 'particles' } }); // a style that uses both
|
||||
ctl.api._pcAcquire();
|
||||
assert.equal(ctl.api.intens.disabled, false, 'precondition: both enabled off-venue');
|
||||
assert.equal(ctl.api.react.disabled, false);
|
||||
|
||||
// Venue turns on: the effective style is now 'venue', which uses neither.
|
||||
// The transition arrives on the settings bus as the 'venueScene' key.
|
||||
ctl.sandbox._venueSceneOverride = true;
|
||||
ctl.emit('venueScene');
|
||||
assert.equal(ctl.api.intens.disabled, true, 'intensity should grey under Venue');
|
||||
assert.equal(ctl.api.react.disabled, true, 'reactive should grey under Venue');
|
||||
assert.equal(ctl.api.sel.disabled, true, 'the dropdown should be inert under Venue too');
|
||||
assert.match(ctl.api.intens.title, /venue/i, 'reason should mention Venue');
|
||||
// All three inert controls point at the reason under Venue (kills a
|
||||
// 'describe reactive only' regression on the select/intensity paths).
|
||||
const vReason = ctl.api.reason.id;
|
||||
assert.equal(ctl.api.sel['aria-describedby'], vReason, 'select must reference the reason under Venue');
|
||||
assert.equal(ctl.api.intens['aria-describedby'], vReason, 'intensity must reference the reason under Venue');
|
||||
assert.equal(ctl.api.react['aria-describedby'], vReason, 'reactive must reference the reason under Venue');
|
||||
assert.match(ctl.api.reason.textContent, /venue/i, 'the reason span carries the Venue text');
|
||||
|
||||
// The dropdown still shows the stored style (venue has no option), but
|
||||
// selecting must not write while it's inert.
|
||||
assert.equal(ctl.api.sel.value, 'particles');
|
||||
const before = ctl.writes.length;
|
||||
ctl.api.sel.value = 'lights';
|
||||
ctl.api.sel.fire('change');
|
||||
assert.equal(ctl.writes.length, before, 'a disabled dropdown must not write');
|
||||
|
||||
// Venue off: controls come back per the stored style.
|
||||
ctl.sandbox._venueSceneOverride = false;
|
||||
ctl.emit('venueScene');
|
||||
assert.equal(ctl.api.intens.disabled, false, 'intensity re-enables when Venue exits');
|
||||
assert.equal(ctl.api.react.disabled, false);
|
||||
assert.equal(ctl.api.sel.disabled, false, 'the dropdown re-enables when Venue exits');
|
||||
assert.equal(ctl.api.sel.title, 'Background style', 'the base tooltip must come back, not blank');
|
||||
assert.equal(ctl.api.sel['aria-describedby'], undefined, 'select drops the reason off-Venue');
|
||||
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'intensity drops the reason off-Venue');
|
||||
ctl.api._pcRelease();
|
||||
});
|
||||
|
||||
test('an unknown style enables both controls (fails open)', () => {
|
||||
const { api, store, emit } = load();
|
||||
api._pcAcquire();
|
||||
store.style = 'some_future_style';
|
||||
emit('style');
|
||||
assert.equal(api.intens.disabled, false);
|
||||
assert.equal(api.react.disabled, false);
|
||||
});
|
||||
|
||||
test('greyed-out controls cannot reach the setters', () => {
|
||||
const { api, store, emit, writes } = load();
|
||||
api._pcAcquire();
|
||||
store.style = 'video'; // uses neither setting
|
||||
emit('style');
|
||||
const before = writes.length;
|
||||
api.intens.fire('change');
|
||||
api.react.fire('click');
|
||||
assert.equal(writes.length, before, 'an inert control must not write');
|
||||
});
|
||||
|
||||
test('greyed-out controls explain themselves on hover', () => {
|
||||
const { api, store, emit } = load();
|
||||
api._pcAcquire();
|
||||
store.style = 'butterchurn';
|
||||
emit('style');
|
||||
assert.match(api.react.title, /butterchurn/i);
|
||||
assert.match(api.intens.title, /butterchurn/i);
|
||||
});
|
||||
|
||||
// A native-disabled <button>/<input> fires no pointer events, so its own
|
||||
// `title` never shows on hover. The reason must therefore also sit on the
|
||||
// non-disabled wrapper, and the disabled control must let the hover fall
|
||||
// through (pointer-events:none) — otherwise the "says why on hover" feature is
|
||||
// dead in the browser while these tests pass on the swallowed control title.
|
||||
test('the greyed-out reason reaches a hoverable wrapper', () => {
|
||||
const { api, store, emit } = load();
|
||||
api._pcAcquire();
|
||||
store.style = 'video'; // uses neither setting
|
||||
emit('style');
|
||||
|
||||
assert.match(api.react.parentNode.title, /nothing to adjust/i,
|
||||
'reactive reason must be on the wrapper, not only the disabled pill');
|
||||
assert.equal(api.react.style.pointerEvents, 'none',
|
||||
'disabled pill must pass hover through to its wrapper');
|
||||
|
||||
assert.match(api.intens.parentNode.title, /nothing to adjust/i,
|
||||
'intensity reason must be on the wrapper, not only the disabled slider');
|
||||
assert.equal(api.intens.style.pointerEvents, 'none',
|
||||
'disabled slider must pass hover through to its wrapper');
|
||||
|
||||
// ...and an enabled style clears the wrapper so the control's own title wins.
|
||||
store.style = 'particles';
|
||||
emit('style');
|
||||
assert.equal(api.react.parentNode.title, '');
|
||||
assert.equal(api.intens.parentNode.title, '');
|
||||
assert.equal(api.intens.style.pointerEvents, '');
|
||||
});
|
||||
@@ -23,6 +23,7 @@
|
||||
const INSTRUMENTS = {
|
||||
guitar: { label: 'Guitar', mode: 'audio' },
|
||||
bass: { label: 'Bass', mode: 'audio' },
|
||||
vocals: { label: 'Vocals', mode: 'audio' },
|
||||
keys: { label: 'Keys / Piano', mode: 'midi' },
|
||||
piano: { label: 'Keys / Piano', mode: 'midi' },
|
||||
drums: { label: 'Drums', mode: 'midi' },
|
||||
@@ -133,16 +134,25 @@
|
||||
const opts2 = sources.map((s) =>
|
||||
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
|
||||
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
|
||||
const hasVocalCal = inst === 'vocals' && !!(window.feedBack && window.feedBack.vocalCalibration &&
|
||||
window.feedBack.vocalCalibration.version === 1 &&
|
||||
typeof window.feedBack.vocalCalibration.launch === 'function');
|
||||
// For vocals: "Calibrate" iff vocal-cal facade present; "Continue" otherwise.
|
||||
// For guitar/bass: "Calibrate" iff noteDetect present; "Continue" otherwise.
|
||||
const canCalibrate = inst === 'vocals' ? hasVocalCal : hasDetector;
|
||||
const notLoadedNotice = inst === 'vocals'
|
||||
? (hasVocalCal ? '' : '<p class="text-xs text-fb-textDim mt-3">Vocal calibration isn’t available yet — you can set it up later from the player.</p>')
|
||||
: (hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isn’t loaded here — you can calibrate later from the player.</p>');
|
||||
const body =
|
||||
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
|
||||
(sources.length
|
||||
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
|
||||
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
|
||||
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
|
||||
(hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isn’t loaded here — you can calibrate later from the player.</p>');
|
||||
notLoadedNotice;
|
||||
const foot =
|
||||
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
|
||||
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
|
||||
(canCalibrate ? 'Calibrate' : 'Continue') + '</button>';
|
||||
shell(inst, body, foot);
|
||||
|
||||
const sel = host.querySelector('[data-is-audio]');
|
||||
@@ -162,8 +172,42 @@
|
||||
// Tell the tuner tables / note_detect which instrument this is.
|
||||
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
|
||||
|
||||
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
||||
if (hasDetector) {
|
||||
const calBtn = host.querySelector('[data-is-cal]');
|
||||
let _advancing = false; // double-click guard for the auto-advance fallback
|
||||
calBtn.addEventListener('click', () => {
|
||||
if (inst === 'vocals') {
|
||||
// Vocals calibration is handled by the vocal-highway plugin's
|
||||
// facade. Guard: if the facade is absent (plugin disabled or
|
||||
// not yet loaded), mark done with a notice and continue.
|
||||
const vc = window.feedBack && window.feedBack.vocalCalibration;
|
||||
if (vc && vc.version === 1 && typeof vc.launch === 'function') {
|
||||
const ov = document.getElementById('input-setup-overlay');
|
||||
const prevDisplay = ov ? ov.style.display : '';
|
||||
if (ov) ov.style.display = 'none';
|
||||
const restore = () => {
|
||||
const o = document.getElementById('input-setup-overlay');
|
||||
if (o) o.style.display = prevDisplay;
|
||||
};
|
||||
vc.launch({
|
||||
requester: 'input_setup',
|
||||
onDone: (_result) => { restore(); advance(inst, true); },
|
||||
onCancel: () => { restore(); /* stay on panel; user can skip or retry */ },
|
||||
});
|
||||
} else {
|
||||
// ponytail: vocal-highway not loaded — mark done so wizard doesn't hang
|
||||
if (_advancing) return; // double-click guard: one advance per panel
|
||||
_advancing = true;
|
||||
calBtn.disabled = true;
|
||||
const body = host.querySelector('[data-is-body]');
|
||||
if (body) body.innerHTML = '<p class="text-sm text-fb-textDim">Vocal calibration will be available once the Vocal Highway plugin is enabled. Continuing…</p>';
|
||||
// Store timer id so advance() (via _activeCleanup) can cancel it
|
||||
// if the user hits Skip before the 1800ms fires — prevents the
|
||||
// stale timer from double-advancing the wizard and dropping the
|
||||
// next instrument from both completed and skipped.
|
||||
const _timerId = setTimeout(() => advance(inst, true), 1800);
|
||||
_activeCleanup = () => clearTimeout(_timerId);
|
||||
}
|
||||
} else if (hasDetector) {
|
||||
// Hide our own full-screen overlay while note_detect's
|
||||
// Calibration Wizard runs on top. That wizard goes
|
||||
// transparent (pointer-events:none) when it minimizes to
|
||||
@@ -352,7 +396,7 @@
|
||||
|
||||
// Settings-panel re-entry (settings.html "Set up input devices" button).
|
||||
// Re-runs the wizard for the player's selected instrument paths, falling
|
||||
// back to all instruments when progression isn't available.
|
||||
// back to all instruments when progression isn’t available.
|
||||
window._inputSetupRelaunch = async function () {
|
||||
let instruments = [];
|
||||
try {
|
||||
@@ -363,7 +407,7 @@
|
||||
instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean);
|
||||
}
|
||||
} catch (_) { /* offline — fall back below */ }
|
||||
if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums'];
|
||||
if (!instruments.length) instruments = ['guitar', 'bass', 'vocals', 'keys', 'drums'];
|
||||
launch(instruments);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -7,15 +7,26 @@
|
||||
#
|
||||
# Pin to Tailwind 3.x so the input/config syntax matches what was
|
||||
# already shipped via the Play CDN (Tailwind 4 has breaking changes).
|
||||
#
|
||||
# Run this from a checkout with NO untracked plugin directories present (a
|
||||
# `git worktree add --detach` of this branch is the safest way). The content
|
||||
# glob (tailwind.config.js) scans `./plugins/**` on disk regardless of
|
||||
# .gitignore — a dev machine with private/out-of-tree plugins checked out
|
||||
# locally (e.g. audio_engine, plugin_manager) will silently bake their classes
|
||||
# into the committed CSS, which CI's clean checkout can never reproduce and
|
||||
# will permanently fail the tailwind-fresh gate.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
# Pin to the exact version used to generate the committed CSS — committed
|
||||
# artifacts must rebuild byte-stable for diff-friendly maintenance. The
|
||||
# pinned version is the one that produced the current static/tailwind.min.css
|
||||
# (visible in its top-of-file header comment); bump deliberately when you
|
||||
# want to track upstream Tailwind 3.x updates, and regenerate the CSS in
|
||||
# the same commit.
|
||||
exec npx -y tailwindcss@3.4.19 \
|
||||
# Byte-stable rebuilds require the exact same resolved dependency tree, not
|
||||
# just the same top-level tailwindcss version: `npx -y tailwindcss@x.y.z`
|
||||
# installs into a scratch npx cache and lets npm re-resolve transitive deps
|
||||
# (postcss, cssnano, autoprefixer) to whatever's current on the registry at
|
||||
# invocation time — those drift independently of the pinned version and
|
||||
# silently produced non-reproducible output between two machines. tailwindcss
|
||||
# is now a pinned devDependency (package.json/package-lock.json); `npm ci`
|
||||
# before this script (both here and in CI) is what actually makes the output
|
||||
# reproducible.
|
||||
exec npx tailwindcss \
|
||||
-c tailwind.config.js \
|
||||
-i static/_tailwind.src.css \
|
||||
-o static/tailwind.min.css \
|
||||
|
||||
@@ -49,7 +49,7 @@ import demo_mode
|
||||
import scan
|
||||
import tailwind_rebuild
|
||||
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||
from routers import tunings as tunings_router
|
||||
import enrichment
|
||||
from routers import art as art_router
|
||||
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
|
||||
@app.post("/api/rescan")
|
||||
def trigger_rescan():
|
||||
"""Manually trigger a library rescan."""
|
||||
if not scan.kick_scan():
|
||||
# force=True: a manual Refresh must skip the directory-signature fast path —
|
||||
# it is the escape hatch for the one change dir mtimes can't see (a pack
|
||||
# rewritten in place under the same name).
|
||||
if not scan.kick_scan(force=True):
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Rescan started"}
|
||||
|
||||
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
|
||||
# delete_missing() prunes anything genuinely gone at the end.
|
||||
meta_db.conn.execute("UPDATE songs SET mtime = -1")
|
||||
meta_db.conn.commit()
|
||||
if not scan.kick_scan():
|
||||
if not scan.kick_scan(force=True, allow_mass_prune=True):
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Full rescan started"}
|
||||
|
||||
@@ -1615,6 +1618,12 @@ app.include_router(media_router.router)
|
||||
app.include_router(ws_highway.router)
|
||||
|
||||
|
||||
# ── Session-sync relay WebSocket (feedBack#1030) ─────────────────────────────
|
||||
# Dumb JSON fan-out rooms for cross-device followers (splitscreen LAN mode).
|
||||
# Implementation in lib/routers/ws_sync.py.
|
||||
app.include_router(ws_sync.router)
|
||||
|
||||
|
||||
# ── Audio serving ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+57
-5
@@ -1150,7 +1150,7 @@ window.feedBack.on('song:ready', () => {
|
||||
let _arrBusyGen = 0;
|
||||
let _arrBusyTimeout = null;
|
||||
|
||||
async function changeArrangement(index) {
|
||||
async function changeArrangement(index, drumPart) {
|
||||
if (currentFilename) {
|
||||
// Tear down any pending fresh-load credits before switching: the
|
||||
// no-count-in hold timer would otherwise fire togglePlay() against the
|
||||
@@ -1276,11 +1276,38 @@ async function changeArrangement(index) {
|
||||
_resetSectionPracticeLog();
|
||||
invalidateParentCount();
|
||||
|
||||
window.highway.reconnect(currentFilename, index);
|
||||
// Carry the selected drum part across the re-stream. An explicit
|
||||
// `drumPart` (a drum-part switch, from changeDrumPart) wins; otherwise
|
||||
// preserve the current picker selection so an ARRANGEMENT switch keeps
|
||||
// the chosen part (drum parts are song-level, not per-arrangement).
|
||||
const part = drumPart !== undefined
|
||||
? drumPart
|
||||
: (document.getElementById('drum-part-select')?.value || '');
|
||||
window.highway.reconnect(currentFilename, index, part);
|
||||
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
|
||||
}
|
||||
}
|
||||
|
||||
// Switch which drum part plays (feedpak 1.17.0 "drums as arrangements"). A part
|
||||
// switch re-streams the same song with a different drum tab — the same
|
||||
// transition as an arrangement switch — so it delegates to changeArrangement
|
||||
// with the CURRENT arrangement held and the new part applied. Wired to
|
||||
// #drum-part-select's onchange; the select is populated + shown by
|
||||
// highway.js's song_info handler only when the song has 2+ drum parts.
|
||||
async function changeDrumPart(partId) {
|
||||
if (!currentFilename) return;
|
||||
let index = 0;
|
||||
const si = window.highway && typeof window.highway.getSongInfo === 'function'
|
||||
? window.highway.getSongInfo() : null;
|
||||
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
|
||||
index = si.arrangement_index;
|
||||
} else {
|
||||
const arrSel = document.getElementById('arr-select');
|
||||
if (arrSel && arrSel.value !== '') index = Number(arrSel.value) || 0;
|
||||
}
|
||||
return changeArrangement(index, partId);
|
||||
}
|
||||
|
||||
// Restart the current song from the beginning (or from loop A when an A–B
|
||||
// loop is armed). Uses the canonical _audioSeek funnel only — never touches
|
||||
// audio.currentTime directly and never reloads via playSong().
|
||||
@@ -1334,12 +1361,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
||||
// leaving the player still leaves — and abandons the queue.
|
||||
window.feedBack.playQueue = (function () {
|
||||
let list = [], idx = -1, source = '', arrangements = null;
|
||||
// Set true by _play() right before it drives playSong, consumed once by
|
||||
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
|
||||
// signal is options.fromQueue, but a chain of plugin playSong wrappers
|
||||
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the options object — so the flag
|
||||
// never arrived and the queue cleared itself the instant its first song
|
||||
// started (a gig/album/playlist never advanced). This flag rides beside the
|
||||
// wrapper chain, not through it.
|
||||
let _internalPlay = false;
|
||||
const active = () => idx >= 0 && idx < list.length;
|
||||
const hasNext = () => active() && idx < list.length - 1;
|
||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||
function _play(i) {
|
||||
const fn = list[i];
|
||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
||||
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
|
||||
// that survives wrapper chains dropping the options arg. Both set; either
|
||||
// suffices. playSong runs its clear-guard synchronously at entry, and the
|
||||
// wrapper chain reaches it synchronously, so the flag is still set then.
|
||||
_internalPlay = true;
|
||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||
}
|
||||
function start(files, opts) {
|
||||
@@ -1371,6 +1411,15 @@ window.feedBack.playQueue = (function () {
|
||||
}
|
||||
return {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
// True when the current song is a queue ADVANCE (song 2..N of a set),
|
||||
// false for its first song or a standalone play. The venue uses this to
|
||||
// fly in once on arrival at the set, then continue the room between
|
||||
// songs instead of replaying the arrival flyover every track.
|
||||
isContinuation: function () { return active() && idx > 0; },
|
||||
// One-shot: true iff _play just kicked off this playSong. Consumed on
|
||||
// read so a later MANUAL play still clears the queue. playSong calls this
|
||||
// instead of trusting options.fromQueue to survive the wrapper chain.
|
||||
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
@@ -2297,11 +2346,14 @@ configureHost({
|
||||
currentFilename: () => currentFilename,
|
||||
});
|
||||
|
||||
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
|
||||
// script and called esc() back when app.js was one too and it was an implicit
|
||||
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
|
||||
Object.assign(window, {
|
||||
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
|
||||
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
|
||||
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
|
||||
changeArrangement, changeDrumPart, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
|
||||
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
||||
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
||||
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }),
|
||||
visualization: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated highway renderer providers: discovery, picker selection, auto-match attribution, failure fallback, and redaction-safe diagnostics.' }),
|
||||
'note-detection': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings (spec 009); consumers own judgment, hit/miss flow as observability events.' }),
|
||||
'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }),
|
||||
});
|
||||
const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
|
||||
|
||||
@@ -1536,4 +1537,4 @@
|
||||
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
|
||||
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
|
||||
} catch (_) {}
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Chart-transform provider registration, selection, and diagnostics.
|
||||
// Transformation stays on the synchronous highway data plane and runs after
|
||||
// difficulty filtering; the selected provider is shared by highway instances.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
const capabilities = window.feedBack.capabilities;
|
||||
if (!capabilities || capabilities.version !== 1) return;
|
||||
if (window.feedBack.chartTransformDomain && window.feedBack.chartTransformDomain.version === 1) return;
|
||||
|
||||
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
|
||||
const PUBLIC_FAILURE_REASON = 'Chart transform provider failed';
|
||||
|
||||
// providerId → { id, label, pluginId, transform }
|
||||
const providers = new Map();
|
||||
let activeProviderId = null;
|
||||
let activeSource = 'startup';
|
||||
let lastFailure = null;
|
||||
// Count of highway instances the active provider is installed on
|
||||
// (the primary window.highway plus any announced via highway:created —
|
||||
// e.g. splitscreen panels). 0 = nothing capable exists yet.
|
||||
let installedCount = 0;
|
||||
// Known highway surfaces beyond window.highway, held weakly so closed
|
||||
// splitscreen panels can be collected. WeakRef is guarded for minimal
|
||||
// test environments; the strong-ref fallback only over-retains there.
|
||||
const _HasWeakRef = typeof WeakRef === 'function';
|
||||
let _surfaces = [];
|
||||
|
||||
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
|
||||
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
|
||||
|
||||
function _snapshot(extra = {}) {
|
||||
return {
|
||||
available: true,
|
||||
active: activeProviderId,
|
||||
activeSource,
|
||||
installed: installedCount > 0,
|
||||
surfaces: installedCount,
|
||||
providers: [...providers.values()].map(p => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
pluginId: p.pluginId,
|
||||
})),
|
||||
lastFailure: lastFailure ? { ...lastFailure } : null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function _emit(name, detail) {
|
||||
try { capabilities.emitEvent('chart-transform', name, detail || {}); }
|
||||
catch (_) { /* eventing must not break rendering */ }
|
||||
}
|
||||
|
||||
function _contributeDiagnostics() {
|
||||
const diagnostics = window.feedBack && window.feedBack.diagnostics;
|
||||
if (diagnostics && typeof diagnostics.contribute === 'function') {
|
||||
try {
|
||||
diagnostics.contribute('chart-transform-capability', {
|
||||
schema: 'feedBack.chart_transform.diagnostics.v1',
|
||||
..._snapshot(),
|
||||
});
|
||||
} catch (_) { /* diagnostics must not break rendering */ }
|
||||
}
|
||||
}
|
||||
|
||||
function _persistSelection(providerId) {
|
||||
try {
|
||||
if (providerId) window.localStorage.setItem(STORAGE_KEY, providerId);
|
||||
else window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (_) { /* storage unavailable → in-memory selection only */ }
|
||||
}
|
||||
|
||||
function _persistedSelection() {
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
|
||||
catch (_) { return null; }
|
||||
}
|
||||
|
||||
function _capable(hw) {
|
||||
return !!(hw && typeof hw.setChartTransform === 'function');
|
||||
}
|
||||
|
||||
// Every capable highway surface: window.highway plus live announced
|
||||
// instances (splitscreen panels), deduped, dead refs pruned in place.
|
||||
function _eachSurface(fn) {
|
||||
const seen = new Set();
|
||||
const primary = window.highway;
|
||||
if (_capable(primary)) { seen.add(primary); fn(primary); }
|
||||
const live = [];
|
||||
for (const ref of _surfaces) {
|
||||
const hw = _HasWeakRef ? ref.deref() : ref;
|
||||
if (!hw) continue;
|
||||
live.push(ref);
|
||||
if (seen.has(hw) || !_capable(hw)) continue;
|
||||
seen.add(hw);
|
||||
fn(hw);
|
||||
}
|
||||
_surfaces = live;
|
||||
return seen.size;
|
||||
}
|
||||
|
||||
function _rememberSurface(hw) {
|
||||
if (!_capable(hw) || hw === window.highway) return;
|
||||
let known = false;
|
||||
_eachSurface(() => {});
|
||||
for (const ref of _surfaces) {
|
||||
if ((_HasWeakRef ? ref.deref() : ref) === hw) { known = true; break; }
|
||||
}
|
||||
if (!known) _surfaces.push(_HasWeakRef ? new WeakRef(hw) : hw);
|
||||
}
|
||||
|
||||
// Hand the current selection to every highway surface (or clear it).
|
||||
// Selection survives with zero surfaces — it re-applies as instances
|
||||
// appear (song:ready for the primary, highway:created for panels).
|
||||
function _install() {
|
||||
const provider = activeProviderId ? providers.get(activeProviderId) : null;
|
||||
const payload = provider ? { id: provider.id, transform: provider.transform } : null;
|
||||
installedCount = 0;
|
||||
_eachSurface((hw) => {
|
||||
try {
|
||||
hw.setChartTransform(payload);
|
||||
if (payload) installedCount += 1;
|
||||
} catch (_) { /* one broken surface must not block the rest */ }
|
||||
});
|
||||
return installedCount > 0 || payload === null;
|
||||
}
|
||||
|
||||
function _setActive(providerId, source) {
|
||||
const from = activeProviderId;
|
||||
activeProviderId = providerId;
|
||||
activeSource = String(source || 'unknown');
|
||||
_persistSelection(providerId);
|
||||
_install();
|
||||
if (from !== providerId) {
|
||||
_emit('transform-changed', { from, to: providerId, source: activeSource });
|
||||
}
|
||||
_contributeDiagnostics();
|
||||
}
|
||||
|
||||
function _payload(ctx = {}) {
|
||||
return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
|
||||
}
|
||||
|
||||
function _providersForParticipant(participantId) {
|
||||
return [...providers.values()].filter(provider => provider.pluginId === participantId);
|
||||
}
|
||||
|
||||
function _registerProviderParticipant(participantId) {
|
||||
const owned = _providersForParticipant(participantId);
|
||||
if (!owned.length) return;
|
||||
capabilities.registerParticipant(participantId, {
|
||||
'chart-transform': {
|
||||
roles: ['provider'],
|
||||
operations: ['chart.transform'],
|
||||
events: [],
|
||||
mode: 'active',
|
||||
compatibility: 'none',
|
||||
safety: 'safe',
|
||||
runtime: true,
|
||||
description: `${owned.length} registered chart transform provider${owned.length === 1 ? '' : 's'}.`,
|
||||
provider_policy: {
|
||||
providerIds: owned.map(provider => provider.id),
|
||||
providers: owned.map(provider => ({ id: provider.id, label: provider.label })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function _registerProvider(ctx = {}) {
|
||||
const payload = _payload(ctx);
|
||||
const providerId = String(payload.providerId || payload.id || '').trim();
|
||||
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
|
||||
if (typeof payload.transform !== 'function') {
|
||||
return _degraded('Provider registration requires a transform(input) function', _snapshot());
|
||||
}
|
||||
const participantId = String(ctx.source || ctx.requester || providerId);
|
||||
const existing = providers.get(providerId);
|
||||
if (existing && existing.pluginId !== participantId) {
|
||||
return _degraded(
|
||||
`Provider ${providerId} is already registered by a different participant`,
|
||||
_snapshot(),
|
||||
);
|
||||
}
|
||||
providers.set(providerId, {
|
||||
id: providerId,
|
||||
label: String(payload.label || providerId),
|
||||
pluginId: participantId,
|
||||
transform: payload.transform,
|
||||
});
|
||||
_registerProviderParticipant(participantId);
|
||||
_emit('provider-registered', { providerId });
|
||||
// Restore a persisted selection the moment its provider appears.
|
||||
if (!activeProviderId && _persistedSelection() === providerId) {
|
||||
_setActive(providerId, 'restore-selection');
|
||||
} else if (activeProviderId === providerId) {
|
||||
// Re-registration after script rehydration: reinstall the fresh
|
||||
// transform closure so the highway isn't holding a stale one.
|
||||
_install();
|
||||
}
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ registered: providerId }));
|
||||
}
|
||||
|
||||
function _unregisterProvider(ctx = {}) {
|
||||
const payload = _payload(ctx);
|
||||
const providerId = String(payload.providerId || payload.id || '').trim();
|
||||
const provider = providers.get(providerId);
|
||||
if (!provider) return _degraded(`Unknown chart-transform provider: ${providerId || '(none)'}`, _snapshot());
|
||||
const callerId = String(ctx.source || ctx.requester || providerId);
|
||||
if (provider.pluginId !== callerId) {
|
||||
return _degraded(
|
||||
`Provider ${providerId} can only be unregistered by its original registrant`,
|
||||
_snapshot(),
|
||||
);
|
||||
}
|
||||
providers.delete(providerId);
|
||||
if (activeProviderId === providerId) {
|
||||
// Keep the persisted selection so the provider re-activates on
|
||||
// its next registration; just detach it from the highway.
|
||||
activeProviderId = null;
|
||||
_install();
|
||||
_emit('transform-changed', { from: providerId, to: null, source: 'provider-unregistered' });
|
||||
}
|
||||
const remainingProviders = _providersForParticipant(provider.pluginId);
|
||||
if (remainingProviders.length) {
|
||||
_registerProviderParticipant(provider.pluginId);
|
||||
} else if (typeof capabilities.unregisterParticipant === 'function') {
|
||||
const live = typeof capabilities.inspect === 'function' ? capabilities.inspect('chart-transform') : null;
|
||||
const participant = ((live && live.participants) || []).find(p => p.pluginId === provider.pluginId);
|
||||
const roles = participant && Array.isArray(participant.roles) ? participant.roles : [];
|
||||
const providerOnly = roles.length === 1 && roles[0] === 'provider';
|
||||
if (!participant || providerOnly) {
|
||||
try { capabilities.unregisterParticipant(provider.pluginId, 'chart-transform'); }
|
||||
catch (_) { /* participant cleanup is best-effort */ }
|
||||
}
|
||||
}
|
||||
_emit('provider-unregistered', { providerId });
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ unregistered: providerId }));
|
||||
}
|
||||
|
||||
function _targetProviderId(ctx = {}) {
|
||||
const payload = _payload(ctx);
|
||||
const target = ctx.target && typeof ctx.target === 'object' ? ctx.target : {};
|
||||
return String(
|
||||
target.providerId || target.provider_id || target.id
|
||||
|| payload.providerId || payload.provider_id || payload.id
|
||||
|| (typeof ctx.target === 'string' ? ctx.target : '') || ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function _selectProvider(ctx = {}) {
|
||||
const providerId = _targetProviderId(ctx);
|
||||
if (!providerId) return _degraded('Transform selection requires a provider id', _snapshot());
|
||||
if (!providers.has(providerId)) {
|
||||
return _degraded(`Unknown chart-transform provider: ${providerId}`, _snapshot());
|
||||
}
|
||||
_setActive(providerId, ctx.requester ? `command:${ctx.requester}` : 'command');
|
||||
return _handled(_snapshot({ selected: providerId }));
|
||||
}
|
||||
|
||||
function _clearProvider(ctx = {}) {
|
||||
_setActive(null, ctx.requester ? `command:${ctx.requester}` : 'command');
|
||||
return _handled(_snapshot({ cleared: true }));
|
||||
}
|
||||
|
||||
function _refresh() {
|
||||
if (!activeProviderId || installedCount === 0) return _handled(_snapshot({ refreshed: false }));
|
||||
let refreshed = 0;
|
||||
_eachSurface((hw) => {
|
||||
if (typeof hw.refreshChartTransform !== 'function') return;
|
||||
try { hw.refreshChartTransform(); refreshed += 1; }
|
||||
catch (_) { /* one broken surface must not block the rest */ }
|
||||
});
|
||||
return _handled(_snapshot({ refreshed: refreshed > 0 }));
|
||||
}
|
||||
|
||||
capabilities.registerOwner('chart-transform', {
|
||||
pluginId: 'core.chart-transform',
|
||||
kind: 'provider-coordinator',
|
||||
safety: 'safe',
|
||||
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'clear-provider', 'refresh'],
|
||||
operations: ['chart.transform'],
|
||||
events: ['provider-registered', 'provider-unregistered', 'transform-changed', 'transform-failed'],
|
||||
description: 'Owns chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with selection, refresh, and failure attribution.',
|
||||
handlers: {
|
||||
inspect: () => _handled(_snapshot()),
|
||||
'list-providers': () => _handled(_snapshot()),
|
||||
'register-provider': (ctx) => _registerProvider(ctx),
|
||||
'unregister-provider': (ctx) => _unregisterProvider(ctx),
|
||||
'select-provider': (ctx) => _selectProvider(ctx),
|
||||
'clear-provider': (ctx) => _clearProvider(ctx),
|
||||
refresh: () => _refresh(),
|
||||
},
|
||||
});
|
||||
|
||||
// Bus mirroring (guarded: the bus may not exist in minimal/test envs).
|
||||
const sm = window.feedBack;
|
||||
if (typeof sm.on === 'function') {
|
||||
try {
|
||||
sm.on('highway:chart-transform-failed', (e) => {
|
||||
const detail = (e && e.detail) || e || {};
|
||||
lastFailure = {
|
||||
providerId: String(detail.id || activeProviderId || 'unknown'),
|
||||
reason: PUBLIC_FAILURE_REASON,
|
||||
};
|
||||
_emit('transform-failed', { ...lastFailure });
|
||||
_contributeDiagnostics();
|
||||
});
|
||||
// The primary highway is created after this module evaluates —
|
||||
// install a pending selection once a song is loading/ready.
|
||||
sm.on('song:ready', () => {
|
||||
if (activeProviderId && installedCount === 0 && _install()) {
|
||||
// setChartTransform restages immediately, so the chart
|
||||
// that just became ready picks the transform up now.
|
||||
_contributeDiagnostics();
|
||||
}
|
||||
});
|
||||
// Additional instances restage the active provider against their
|
||||
// own chart state.
|
||||
sm.on('highway:created', (e) => {
|
||||
const detail = (e && e.detail) || e || {};
|
||||
if (!_capable(detail.highway)) return;
|
||||
_rememberSurface(detail.highway);
|
||||
if (activeProviderId) _install();
|
||||
_contributeDiagnostics();
|
||||
});
|
||||
} catch (_) { /* bus mirroring is best-effort */ }
|
||||
}
|
||||
|
||||
window.feedBack.chartTransformDomain = {
|
||||
version: 1,
|
||||
snapshot: _snapshot,
|
||||
};
|
||||
_contributeDiagnostics();
|
||||
})();
|
||||
+268
-25
@@ -267,6 +267,19 @@ function createHighway() {
|
||||
hwState._filteredChords = null;
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
// Transform stage; null fields fall through to filtered/original data.
|
||||
hwState._xfProvider = null; // { id, transform } or null
|
||||
hwState._xfNotes = null; // effective (post-filter) views
|
||||
hwState._xfChords = null;
|
||||
hwState._xfAnchors = null;
|
||||
hwState._xfNotesAll = null; // full-difficulty views (getNotes/getChords)
|
||||
hwState._xfChordsAll = null;
|
||||
hwState._xfChordTemplates = null;
|
||||
hwState._xfStringCount = null; // number or null
|
||||
hwState._xfTuning = null; // array or null
|
||||
hwState._xfCapo = null; // number or null
|
||||
hwState._xfHandShapes = null; // array or null
|
||||
hwState._xfCentOffset = null; // number or null
|
||||
// Tracks whether ANY phrase level carries handshape data. Lets us
|
||||
// distinguish "this difficulty has none" (respect strictly — even
|
||||
// when empty) from "the chart's phrase data never authored any
|
||||
@@ -397,7 +410,8 @@ function createHighway() {
|
||||
function getAnchorAt(t) {
|
||||
// Same master-difficulty fallback as the render loops — the
|
||||
// anchor ladder pairs with the note ladder.
|
||||
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
let a = src[0] || { fret: 1, width: 4 };
|
||||
for (const anc of src) {
|
||||
if (anc.time > t) break;
|
||||
@@ -408,7 +422,8 @@ function createHighway() {
|
||||
|
||||
function getMaxFretInWindow(t) {
|
||||
// Find the highest fret needed across all anchors visible on screen
|
||||
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
let maxFret = 0;
|
||||
for (const anc of src) {
|
||||
if (anc.time > t + VISIBLE_SECONDS + 2) break; // Skip anchors well in the future (with a little buffer to avoid moving early the cutoff)
|
||||
@@ -541,17 +556,20 @@ function createHighway() {
|
||||
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
b.notes = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
b.chords = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
b.anchors = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
b.notes = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
b.chords = hwState._xfChords !== null ? hwState._xfChords
|
||||
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
b.anchors = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
b.beats = hwState.beats;
|
||||
b.sections = hwState.sections;
|
||||
b.chordTemplates = hwState.chordTemplates;
|
||||
b.stringCount = hwState.stringCount;
|
||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||
// instrument’s standard open-string layout). Live reference.
|
||||
b.tuning = hwState.songInfo?.tuning;
|
||||
b.capo = hwState.songInfo?.capo;
|
||||
b.chordTemplates = hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
|
||||
b.stringCount = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
|
||||
// Effective tuning metadata; live references like the chart arrays.
|
||||
b.tuning = hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning;
|
||||
b.capo = hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo;
|
||||
b.centOffset = hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset;
|
||||
b.lyrics = hwState.lyrics;
|
||||
b.lyricsSource = hwState.lyricsSource;
|
||||
b.toneChanges = hwState.toneChanges;
|
||||
@@ -572,9 +590,10 @@ function createHighway() {
|
||||
// don't belong. Only fall back to the flat list when the
|
||||
// phrase data carries no handshapes at all (common on DLC
|
||||
// where handshapes ship on the arrangement root).
|
||||
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes;
|
||||
b.handShapes = hwState._xfHandShapes !== null ? hwState._xfHandShapes
|
||||
: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes;
|
||||
|
||||
// Display flags
|
||||
b.inverted = hwState._inverted;
|
||||
@@ -986,6 +1005,22 @@ function createHighway() {
|
||||
// inline arrow function.
|
||||
function _handleAsyncInitFailure(e) {
|
||||
if (hwState._renderer !== _installedRenderer) return;
|
||||
// ...and ignore a rejection from a SUPERSEDED init cycle.
|
||||
//
|
||||
// A renderer mints a fresh readyPromise on every init(), and
|
||||
// rejects the previous one ("superseded") when a newer init
|
||||
// starts. The renderer object is unchanged, so the identity
|
||||
// check above does not catch it — and we would tear down a
|
||||
// perfectly healthy renderer that is merely re-initialising.
|
||||
//
|
||||
// This is exactly what starting a gig did: setViz('venue')
|
||||
// installed the 3D renderer, then the queue's playSong()
|
||||
// re-initialised it a tick later; init #1's promise rejected,
|
||||
// and the gig dropped to the fallback 2D highway with the
|
||||
// venue gone. A superseded init is not a failed init — the
|
||||
// NEW cycle owns the outcome, and its own promise is what we
|
||||
// must judge.
|
||||
if (_installedRenderer.readyPromise !== rp) return;
|
||||
console.error('renderer async init failure:', e);
|
||||
_destroyCurrentIfInited();
|
||||
hwState._renderer = _defaultRenderer;
|
||||
@@ -1159,6 +1194,17 @@ function createHighway() {
|
||||
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
|
||||
}
|
||||
|
||||
// Optional renderer capability: "my picture keeps moving even when the chart
|
||||
// clock is stopped". Anything a renderer animates on its own clock (the 3D
|
||||
// highway's venue video + crowd) has to opt out of the paused-frame throttle
|
||||
// or it renders at 10 fps while the song is paused. Absent / throwing =
|
||||
// false, so every existing renderer keeps the throttle unchanged.
|
||||
function _rendererNeedsContinuousFrames() {
|
||||
const r = hwState._renderer;
|
||||
if (!r || typeof r.needsContinuousFrames !== 'function') return false;
|
||||
try { return r.needsContinuousFrames() === true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function draw() {
|
||||
hwState.animFrame = requestAnimationFrame(draw);
|
||||
if (!hwState.canvas || !hwState._renderer) return;
|
||||
@@ -1223,7 +1269,15 @@ function createHighway() {
|
||||
const _nowP = performance.now();
|
||||
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
||||
_paused = true;
|
||||
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
// ...unless the renderer says its picture is NOT static while
|
||||
// paused. The throttle assumes a paused chart is a still frame,
|
||||
// but a renderer can own content on a clock of its own — the 3D
|
||||
// highway draws the venue's video backdrop and its reactive crowd
|
||||
// into this same canvas, so throttling the highway throttled the
|
||||
// whole room to 10 fps whenever the song was paused. Optional
|
||||
// method: renderers that don't implement it keep the throttle.
|
||||
if (!_rendererNeedsContinuousFrames()
|
||||
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
hwState._lastPausedDrawAt = _nowP;
|
||||
}
|
||||
}
|
||||
@@ -1337,9 +1391,10 @@ function createHighway() {
|
||||
// slots, so 4 strings spread across the full band rather than
|
||||
// using the upper 4/6ths of the 6-string layout. The Math.max
|
||||
// guards against a hypothetical 1-string instrument (denom=0).
|
||||
const span = Math.max(1, hwState.stringCount - 1);
|
||||
for (let i = 0; i < hwState.stringCount; i++) {
|
||||
const yi = hwState._inverted ? (hwState.stringCount - 1 - i) : i;
|
||||
const sc = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
|
||||
const span = Math.max(1, sc - 1);
|
||||
for (let i = 0; i < sc; i++) {
|
||||
const yi = hwState._inverted ? (sc - 1 - i) : i;
|
||||
const y = strTop + (yi / span) * (strBot - strTop);
|
||||
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
|
||||
hwState.ctx.lineWidth = 3;
|
||||
@@ -1442,6 +1497,7 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
_restageChartTransform();
|
||||
return;
|
||||
}
|
||||
const outNotes = [];
|
||||
@@ -1489,6 +1545,116 @@ function createHighway() {
|
||||
}
|
||||
hwState._filteredHandShapes = outHandShapes;
|
||||
hwState._phrasesHaveHandShapes = anyHandShapeInPhrases;
|
||||
_restageChartTransform();
|
||||
}
|
||||
|
||||
function _clearChartTransformStage() {
|
||||
hwState._xfNotes = null;
|
||||
hwState._xfChords = null;
|
||||
hwState._xfAnchors = null;
|
||||
hwState._xfNotesAll = null;
|
||||
hwState._xfChordsAll = null;
|
||||
hwState._xfChordTemplates = null;
|
||||
hwState._xfStringCount = null;
|
||||
hwState._xfTuning = null;
|
||||
hwState._xfCapo = null;
|
||||
hwState._xfHandShapes = null;
|
||||
hwState._xfCentOffset = null;
|
||||
}
|
||||
|
||||
function _cloneChartTransformValue(value, seen = new WeakMap()) {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
const copy = Array.isArray(value) ? new Array(value.length) : {};
|
||||
seen.set(value, copy);
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(copy, key, {
|
||||
value: _cloneChartTransformValue(value[key], seen),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function _sortedChartTransformArray(items, key) {
|
||||
return items.slice().sort((a, b) => a[key] - b[key]);
|
||||
}
|
||||
|
||||
function _reportChartTransformFailure(provider, error) {
|
||||
_clearChartTransformStage();
|
||||
console.error('chart transform:', error);
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
try {
|
||||
window.feedBack.emit('highway:chart-transform-failed', {
|
||||
id: provider.id,
|
||||
});
|
||||
} catch (_) { /* eventing must not break rendering */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Stage one synchronous transform over the difficulty-filtered chart.
|
||||
function _restageChartTransform() {
|
||||
_clearChartTransformStage();
|
||||
const p = hwState._xfProvider;
|
||||
if (!p) return;
|
||||
// Pre-ready there is nothing meaningful to transform (chart arrays
|
||||
// are still streaming, songInfo may be empty) — keep the provider
|
||||
// attached and let the `ready` path (which sets hwState.ready BEFORE
|
||||
// _rebuildMasteryFilter) run the first real staging.
|
||||
if (!hwState.ready) return;
|
||||
const filterActive = hwState._filteredNotes !== null;
|
||||
try {
|
||||
let out = p.transform(_cloneChartTransformValue({
|
||||
notes: filterActive ? hwState._filteredNotes : hwState.notes,
|
||||
chords: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords,
|
||||
anchors: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors,
|
||||
allNotes: hwState.notes,
|
||||
allChords: hwState.chords,
|
||||
chordTemplates: hwState.chordTemplates,
|
||||
// Same effective selection the bundle uses (see b.handShapes).
|
||||
handShapes: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes,
|
||||
stringCount: hwState.stringCount,
|
||||
songInfo: hwState.songInfo,
|
||||
}));
|
||||
if (out && typeof out.then === 'function') {
|
||||
try {
|
||||
const catchAsyncFailure = out.catch;
|
||||
if (typeof catchAsyncFailure === 'function') {
|
||||
catchAsyncFailure.call(out, error => console.error('chart transform async:', error));
|
||||
}
|
||||
} catch (_) { /* the synchronous failure below remains authoritative */ }
|
||||
throw new TypeError('Chart transform providers must return synchronously');
|
||||
}
|
||||
if (!out || typeof out !== 'object') return;
|
||||
out = _cloneChartTransformValue(out);
|
||||
if (Array.isArray(out.notes)) hwState._xfNotes = _sortedChartTransformArray(out.notes, 't');
|
||||
if (Array.isArray(out.chords)) hwState._xfChords = _sortedChartTransformArray(out.chords, 't');
|
||||
if (Array.isArray(out.anchors)) hwState._xfAnchors = _sortedChartTransformArray(out.anchors, 'time');
|
||||
// Full-difficulty views: explicit allNotes/allChords, or reuse the
|
||||
// effective output when no filter is active (effective === raw then).
|
||||
if (Array.isArray(out.allNotes)) hwState._xfNotesAll = _sortedChartTransformArray(out.allNotes, 't');
|
||||
else if (!filterActive && Array.isArray(out.notes)) hwState._xfNotesAll = hwState._xfNotes;
|
||||
if (Array.isArray(out.allChords)) hwState._xfChordsAll = _sortedChartTransformArray(out.allChords, 't');
|
||||
else if (hwState._filteredChords === null && Array.isArray(out.chords)) hwState._xfChordsAll = hwState._xfChords;
|
||||
if (Array.isArray(out.chordTemplates)) hwState._xfChordTemplates = out.chordTemplates;
|
||||
if (Number.isFinite(out.stringCount) && out.stringCount >= 1) {
|
||||
// Same [1, 8] clamp as the song_info stringCount handler.
|
||||
hwState._xfStringCount = Math.max(1, Math.min(8, Math.trunc(out.stringCount)));
|
||||
}
|
||||
if (Array.isArray(out.tuning) && out.tuning.length) hwState._xfTuning = out.tuning;
|
||||
if (Number.isFinite(out.capo) && out.capo >= 0) hwState._xfCapo = Math.trunc(out.capo);
|
||||
if (Array.isArray(out.handShapes)) {
|
||||
hwState._xfHandShapes = _sortedChartTransformArray(out.handShapes, 'start_time');
|
||||
}
|
||||
if (Number.isFinite(out.centOffset)) hwState._xfCentOffset = out.centOffset;
|
||||
} catch (e) {
|
||||
_reportChartTransformFailure(p, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
@@ -1533,6 +1699,8 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
// Keep _xfProvider (persists across songs); drop staged output.
|
||||
_clearChartTransformStage();
|
||||
_resetChordRenderState();
|
||||
},
|
||||
|
||||
@@ -2130,6 +2298,31 @@ function createHighway() {
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
// Drum-part picker (feedpak 1.17.0 "drums as
|
||||
// arrangements"): a song can carry several drum
|
||||
// charts. Populate the picker beside the
|
||||
// arrangement switcher; show it only when there
|
||||
// are 2+ parts to choose between. `drum_parts`
|
||||
// is always present (empty for non-drum songs),
|
||||
// so a single-drum / no-drum song hides it. The
|
||||
// currently-streaming part is marked selected by
|
||||
// the `drum_tab` handler below (authoritative
|
||||
// `part_id`), so we don't guess here.
|
||||
{
|
||||
const dpSel = document.getElementById('drum-part-select');
|
||||
if (dpSel) {
|
||||
const parts = Array.isArray(msg.drum_parts) ? msg.drum_parts : [];
|
||||
dpSel.textContent = '';
|
||||
for (const p of parts) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name || p.id;
|
||||
dpSel.appendChild(opt);
|
||||
}
|
||||
const dpRow = document.getElementById('v3-drum-part-row');
|
||||
if (dpRow) dpRow.classList.toggle('hidden', parts.length <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Plugin context API — broadcast current song state
|
||||
if (window.feedBack) {
|
||||
@@ -2212,7 +2405,22 @@ function createHighway() {
|
||||
name: (typeof msg.name === 'string' && msg.name) ? msg.name : 'Drums',
|
||||
kit: Array.isArray(msg.kit) ? msg.kit : [],
|
||||
hits: [],
|
||||
// Which drum part this stream carries (feedpak
|
||||
// 1.17.0). Present only for multi-part packs;
|
||||
// null otherwise. Plugins can read it via
|
||||
// bundle.drumTab.part_id.
|
||||
part_id: (typeof msg.part_id === 'string' && msg.part_id) ? msg.part_id : null,
|
||||
};
|
||||
// Reflect the authoritative streaming part in the
|
||||
// picker (the server resolves an unknown/absent
|
||||
// selection to the primary, so this keeps the
|
||||
// dropdown honest even after a fallback).
|
||||
if (hwState.drumTab.part_id) {
|
||||
const dpSel = document.getElementById('drum-part-select');
|
||||
if (dpSel && dpSel.value !== hwState.drumTab.part_id) {
|
||||
dpSel.value = hwState.drumTab.part_id;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'drum_hits':
|
||||
if (hwState.drumTab && Array.isArray(msg.data)) {
|
||||
@@ -2419,8 +2627,11 @@ function createHighway() {
|
||||
hwState._domVisSampledFrame = NaN;
|
||||
return _isHighwayVisible();
|
||||
},
|
||||
getNotes() { return hwState.notes; },
|
||||
getChords() { return hwState.chords; },
|
||||
// When a chart transform is active these return its full-difficulty
|
||||
// views (falling through to the original arrays if the provider
|
||||
// supplied only the filtered view).
|
||||
getNotes() { return hwState._xfNotesAll !== null ? hwState._xfNotesAll : hwState.notes; },
|
||||
getChords() { return hwState._xfChordsAll !== null ? hwState._xfChordsAll : hwState.chords; },
|
||||
// Difficulty-filtered variants of getNotes()/getChords(). Returns the
|
||||
// master-difficulty-filtered arrays when the current song has phrase-level
|
||||
// data (i.e. the mastery slider is active). For songs with a single
|
||||
@@ -2428,8 +2639,14 @@ function createHighway() {
|
||||
// these fall through to the raw arrays, the same as getNotes()/getChords().
|
||||
// Plugins that score or analyse only the notes the player is currently
|
||||
// expected to play should prefer these over getNotes()/getChords(). Read-only.
|
||||
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; },
|
||||
getFilteredChords() { return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; },
|
||||
getFilteredNotes() {
|
||||
if (hwState._xfNotes !== null) return hwState._xfNotes;
|
||||
return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
},
|
||||
getFilteredChords() {
|
||||
if (hwState._xfChords !== null) return hwState._xfChords;
|
||||
return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
},
|
||||
// Live reference to the chord-template lookup table —
|
||||
// `getChords()[i].id` is an index into this array. Each
|
||||
// template carries `{ name, fingers, frets }`:
|
||||
@@ -2444,7 +2661,7 @@ function createHighway() {
|
||||
// its entries. Not difficulty-filter-aware (templates are
|
||||
// static metadata; every chord_id referenced by `getChords()`
|
||||
// is guaranteed valid).
|
||||
getChordTemplates() { return hwState.chordTemplates; },
|
||||
getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
|
||||
getToneChanges() { return hwState.toneChanges; },
|
||||
getToneBase() { return hwState.toneBase; },
|
||||
getSections() { return hwState.sections; },
|
||||
@@ -2472,7 +2689,10 @@ function createHighway() {
|
||||
// string-indexed UI / geometry against THIS rather than
|
||||
// assuming 6. Defaults to 6 between songs (until the next
|
||||
// song_info message arrives).
|
||||
getStringCount() { return hwState.stringCount; },
|
||||
getStringCount() { return hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount; },
|
||||
getTuning() { return hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning; },
|
||||
getCapo() { return hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo; },
|
||||
getCentOffset() { return hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset; },
|
||||
addDrawHook(fn) {
|
||||
hwState._drawHooks.push(fn);
|
||||
},
|
||||
@@ -2496,6 +2716,17 @@ function createHighway() {
|
||||
*/
|
||||
setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; },
|
||||
getNoteStateProvider() { return hwState._noteStateProvider; },
|
||||
// Install one synchronous provider for this highway. The capability
|
||||
// domain owns registration and selection; null clears the provider.
|
||||
setChartTransform(p) {
|
||||
hwState._xfProvider = (p && typeof p.transform === 'function')
|
||||
? { id: String(p.id || 'anonymous'), transform: p.transform }
|
||||
: null;
|
||||
_restageChartTransform();
|
||||
},
|
||||
getChartTransform() { return hwState._xfProvider; },
|
||||
// Re-run the installed provider (e.g. its target settings changed).
|
||||
refreshChartTransform() { _restageChartTransform(); },
|
||||
/** Current per-string base colors (copy). Index 0..7. */
|
||||
getStringColors() { return hwState.STRING_COLORS.slice(); },
|
||||
/**
|
||||
@@ -2582,7 +2813,7 @@ function createHighway() {
|
||||
localStorage.setItem('showFingerHints', String(hwState._showFingerHints));
|
||||
},
|
||||
|
||||
reconnect(filename, arrangement) {
|
||||
reconnect(filename, arrangement, drumPart) {
|
||||
// Close old WS but keep audio + animation running
|
||||
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
|
||||
hwState.ready = false;
|
||||
@@ -2603,9 +2834,16 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
// Keep _xfProvider (persists across songs); drop staged output.
|
||||
_clearChartTransformStage();
|
||||
_resetChordRenderState();
|
||||
const wsParams = new URLSearchParams();
|
||||
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
|
||||
// Multiple drum parts (feedpak 1.17.0 "drums as arrangements"):
|
||||
// carry the selected part id so the WS streams ITS drum tab. Empty
|
||||
// / undefined → the primary part (server default), i.e. today's
|
||||
// one-drum behavior for any pack the picker never touched.
|
||||
if (drumPart) wsParams.set('drum_part', drumPart);
|
||||
let namingMode = 'smart';
|
||||
if (typeof window._getArrangementNamingMode === 'function') {
|
||||
const v = window._getArrangementNamingMode();
|
||||
@@ -2700,6 +2938,11 @@ function createHighway() {
|
||||
*/
|
||||
isDefaultRenderer() { return hwState._renderer === _defaultRenderer || hwState._renderer == null; },
|
||||
};
|
||||
// Let cross-instance coordinators discover this highway.
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
try { window.feedBack.emit('highway:created', { highway: api }); }
|
||||
catch (e) { console.error('highway:created emit:', e); }
|
||||
}
|
||||
return api;
|
||||
}
|
||||
const highway = createHighway();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Blob export helpers — the download idiom that used to be duplicated in
|
||||
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
|
||||
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
|
||||
// plugins reach it via dynamic import('/static/js/blob-io.js').
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Copy an image blob to the system clipboard. Returns true on success, false
|
||||
// when the Clipboard API is unavailable or refuses (insecure context, no user
|
||||
// gesture, permission denied) — callers fall back to downloadBlob and say so.
|
||||
export async function copyImageBlob(blob) {
|
||||
try {
|
||||
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+78
-5
@@ -1,4 +1,4 @@
|
||||
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
|
||||
// Count-in — the one-bar click before playback, plus the song-credits overlay that
|
||||
// shares its lifecycle and timers.
|
||||
//
|
||||
// The third slice out of app.js's strongly-connected core, and the first that had to
|
||||
@@ -40,6 +40,75 @@ export function playClick(high = false) {
|
||||
osc.stop(_audioCtx.currentTime + 0.08);
|
||||
}
|
||||
|
||||
// ── How many clicks lead into `startT` ──────────────────────────────────
|
||||
// One bar, derived from the song_timeline beats: `window.highway.getBeats()`
|
||||
// is the only meter data the frontend holds (the `time_signatures` map is
|
||||
// streamed to plugins, not stored here). Beats carry `measure >= 0` on
|
||||
// downbeats, so the gap between consecutive downbeats IS the bar length —
|
||||
// which is why a 3/4 song no longer gets four clicks.
|
||||
//
|
||||
// A first bar shorter than that is a pickup (anacrusis), and the count is
|
||||
// shortened by its length so the music enters on its real beat: a 1-beat
|
||||
// pickup in 4/4 counts "1 2 3" and the pickup lands on 4. Counting a full
|
||||
// four there puts the pickup where the downbeat belongs, and the player comes
|
||||
// in a beat late for the whole song.
|
||||
export function countInBeats(startT) {
|
||||
const DEFAULT = 4; // pre-chart, synthetic highway (minigames), or no beats
|
||||
let beats = null;
|
||||
try {
|
||||
if (window.highway && typeof window.highway.getBeats === 'function') {
|
||||
beats = window.highway.getBeats();
|
||||
}
|
||||
} catch (_) { /* fall through to the default */ }
|
||||
if (!Array.isArray(beats) || beats.length < 2) return DEFAULT;
|
||||
|
||||
const downbeats = [];
|
||||
for (let i = 0; i < beats.length; i++) {
|
||||
if (beats[i] && beats[i].measure >= 0) downbeats.push(i);
|
||||
}
|
||||
if (downbeats.length < 2) return DEFAULT;
|
||||
|
||||
// Bar length = the most common gap between downbeats. The mode rather than
|
||||
// the first gap: it ignores a short pickup bar and a short final bar, and
|
||||
// survives an isolated meter change mid-song. The beats trailing the last
|
||||
// downbeat count as a candidate too — otherwise a song of pickup + one bar
|
||||
// offers only the pickup's own gap and the count collapses to it.
|
||||
const gapCounts = new Map();
|
||||
const addGap = (gap) => gapCounts.set(gap, (gapCounts.get(gap) || 0) + 1);
|
||||
for (let k = 1; k < downbeats.length; k++) {
|
||||
addGap(downbeats[k] - downbeats[k - 1]);
|
||||
}
|
||||
addGap(beats.length - downbeats[downbeats.length - 1]);
|
||||
let barLen = DEFAULT;
|
||||
let bestCount = 0;
|
||||
for (const [gap, n] of gapCounts) {
|
||||
// Tie → the longer bar: a pickup's short gap must not outvote the
|
||||
// real meter when the song is too short to repeat it.
|
||||
if (n > bestCount || (n === bestCount && gap > barLen)) {
|
||||
barLen = gap;
|
||||
bestCount = n;
|
||||
}
|
||||
}
|
||||
|
||||
// The beat playback resumes on. The 50 ms tolerance matches the seek
|
||||
// precision the loop-wrap path already assumes.
|
||||
const startIdx = beats.findIndex(b => b && b.time >= startT - 0.05);
|
||||
if (startIdx === -1) return barLen; // past the last beat
|
||||
if (!(beats[startIdx].measure >= 0)) return barLen; // resuming mid-bar
|
||||
|
||||
const nextDownbeat = downbeats.find(d => d > startIdx);
|
||||
if (nextDownbeat === undefined) return barLen; // the last downbeat
|
||||
const thisBar = nextDownbeat - startIdx;
|
||||
if (thisBar <= 0) return barLen;
|
||||
|
||||
// Only the song's FIRST bar can be a pickup. A short bar anywhere else is
|
||||
// a meter change (or a truncated final bar), and counting it as a pickup
|
||||
// would leave almost no count-in at all — so elsewhere we simply count
|
||||
// that bar's own length, which is also what a mid-song meter change wants.
|
||||
if (startIdx === downbeats[0] && thisBar < barLen) return barLen - thisBar;
|
||||
return thisBar;
|
||||
}
|
||||
|
||||
let _countingIn = false;
|
||||
let _countOverlay = null;
|
||||
// Generation token so teardown can cancel an in-progress count-in. Each
|
||||
@@ -273,12 +342,15 @@ export async function startCountIn(opts = {}) {
|
||||
function beginCount() {
|
||||
const bpm = window.highway.getBPM(loopA);
|
||||
const beatInterval = 60 / bpm;
|
||||
// One bar of the meter at loop A (a short bar there is counted short,
|
||||
// same as the song-start pickup).
|
||||
const clicks = countInBeats(loopA);
|
||||
let count = 0;
|
||||
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
if (count > clicks) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
@@ -320,7 +392,7 @@ export async function startCountIn(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
|
||||
// Start-of-song count-in: a one-bar click before playback begins, gated by the
|
||||
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
|
||||
// overlay + click + gen-token cancellation, but counts from the song's current
|
||||
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
|
||||
@@ -340,14 +412,15 @@ export async function startSongCountIn() {
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = window.highway.getBPM(startT);
|
||||
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
|
||||
// Pre-chart / malformed-tempo fallback: 120 BPM (500 ms per beat).
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
|
||||
const beatInterval = 60 / bpm;
|
||||
const clicks = countInBeats(startT);
|
||||
let count = 0;
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
if (count > clicks) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
// redact toggles.
|
||||
// 3. Stream the returned zip to disk.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
function _diagIncludeFromUI() {
|
||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||
return {
|
||||
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
|
||||
+21
-10
@@ -400,8 +400,9 @@ export function drawSustains(hwState, W, H) {
|
||||
// Same master-difficulty fallback as drawNotes/drawChords —
|
||||
// without this, sustain bars for filtered-out notes would
|
||||
// still render, leaving orphan rectangles where no note head
|
||||
// is drawn.
|
||||
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// is drawn. An active chart transform substitutes its staged view.
|
||||
const src = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
for (const n of src) {
|
||||
if (n.sus <= 0.01) continue;
|
||||
const end = n.t + n.sus;
|
||||
@@ -501,7 +502,9 @@ export function drawNotes(hwState, W, H) {
|
||||
// phrase-level ladder data, render from the mastery-filtered
|
||||
// array. _filteredNotes stays null for slider-disabled sources
|
||||
// so rendering falls through to the flat notes array unchanged.
|
||||
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// An active chart transform (_xfNotes) substitutes its staged view.
|
||||
const src = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// Binary search for visible range
|
||||
const tMin = hwState.currentTime - 0.25;
|
||||
const tMax = hwState.currentTime + VISIBLE_SECONDS;
|
||||
@@ -649,7 +652,8 @@ export function drawUnisonBends(hwState, W, H, drawnNotes) {
|
||||
export function drawChords(hwState, W, H) {
|
||||
// See drawNotes — _filteredChords is null for slider-disabled
|
||||
// sources so we fall through to the flat chords array.
|
||||
const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
const src = hwState._xfChords !== null ? hwState._xfChords
|
||||
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
_ensureChordRenderCache(hwState, src);
|
||||
|
||||
const tMin = hwState.currentTime - 0.25;
|
||||
@@ -674,7 +678,7 @@ export function drawChords(hwState, W, H) {
|
||||
const actualSpread = Math.max(spread, minSpread);
|
||||
const actualTotalH = actualSpread * Math.max(0, sorted.length - 1);
|
||||
|
||||
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const hasNonZero = nonZeroNotes.length >= 1;
|
||||
|
||||
const frameLeftFret = baseFret;
|
||||
@@ -1124,15 +1128,22 @@ export function getChordTemplateInfo(chordId, chordTemplates) {
|
||||
return { tmpl, tmplFrets, getTemplateFret, isOpen };
|
||||
}
|
||||
|
||||
// Effective chord templates: an active chart transform substitutes its
|
||||
// re-indexed table (identity change also invalidates the render cache).
|
||||
export function _effChordTemplates(hwState) {
|
||||
return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
|
||||
}
|
||||
|
||||
// Build _chordRenderInfo for every chord in `src` if the cache is stale.
|
||||
// Two passes over the array: chain bounds, then base-fret resolution
|
||||
// (which can read previous chord's cached baseFret).
|
||||
export function _ensureChordRenderCache(hwState, src) {
|
||||
const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates;
|
||||
const effTemplates = _effChordTemplates(hwState);
|
||||
const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates;
|
||||
if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
|
||||
hwState._chordRenderCacheSrc = src;
|
||||
hwState._chordRenderCacheInverted = hwState._inverted;
|
||||
hwState._chordRenderCacheTemplates = hwState.chordTemplates;
|
||||
hwState._chordRenderCacheTemplates = effTemplates;
|
||||
// Templates feed isOpen() — when they land after `chords`,
|
||||
// _updateFretLinePreview's stashed open/non-open classification
|
||||
// for the currently-active chord is also stale. It only refreshes
|
||||
@@ -1188,7 +1199,7 @@ export function _ensureChordRenderCache(hwState, src) {
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
const info = hwState._chordRenderInfo.get(ch);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, effTemplates);
|
||||
const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s);
|
||||
const nonZero = sortedNotes.filter(cn => !isOpen(cn));
|
||||
const nonZeroFrets = nonZero.map(cn => cn.f);
|
||||
@@ -1248,7 +1259,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
|
||||
ch.t > bestChordTime) {
|
||||
bestChordTime = ch.t;
|
||||
activeChord = ch;
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const nonZero = ch.notes.filter(cn => !isOpen(cn));
|
||||
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
|
||||
}
|
||||
@@ -1260,7 +1271,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
|
||||
const p = project(ch.t - hwState.currentTime);
|
||||
if (!p) continue;
|
||||
activeChord = ch;
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const nonZero = ch.notes.filter(cn => !isOpen(cn));
|
||||
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
|
||||
break;
|
||||
|
||||
+71
-4
@@ -410,6 +410,51 @@ function _applyLibraryProviderToParams(params) {
|
||||
return params;
|
||||
}
|
||||
|
||||
// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
|
||||
// A song's bass chart is often tuned differently from its guitar chart, so the
|
||||
// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
|
||||
// badge must all speak for the instrument the player actually plays. Read the
|
||||
// host's working-tuning capability (the live selection, seeded from
|
||||
// /api/settings at boot) rather than adding another settings fetch; hosts
|
||||
// without the capability keep the guitar behaviour.
|
||||
const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
|
||||
let _libSettingsProfile = '';
|
||||
|
||||
export function _setLibraryProfile(profileId) {
|
||||
_libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
|
||||
}
|
||||
|
||||
export function _libraryInstrument() {
|
||||
// The PROFILE is the only three-valued source (lead / rhythm / bass); the
|
||||
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
|
||||
// so it is only the fallback.
|
||||
if (_libSettingsProfile) return _libSettingsProfile;
|
||||
try {
|
||||
const wt = window.feedBack?.workingTuning;
|
||||
if (wt && typeof wt.get === 'function') {
|
||||
const cur = wt.get();
|
||||
if (cur?.instrument === 'bass') return 'bass';
|
||||
}
|
||||
} catch { /* capability absent/erroring — lead guitar is the safe default */ }
|
||||
return 'guitar-lead';
|
||||
}
|
||||
|
||||
export function _libraryInstrumentLabel() {
|
||||
const p = _libraryInstrument();
|
||||
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
|
||||
}
|
||||
|
||||
// The tuning a row should SHOW: the bass chart's for a bass player, falling
|
||||
// back to the song (guitar-derived) tuning when the song has no bass
|
||||
// arrangement — the common case, not an edge path.
|
||||
function _rowTuningRaw(song) {
|
||||
const p = _libraryInstrument();
|
||||
const field = p === 'bass' ? 'bass_tuning_name'
|
||||
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
|
||||
if (field && song[field]) return song[field];
|
||||
return song.tuning || song.tuning_name || '';
|
||||
}
|
||||
|
||||
export function _resetLibraryProviderViewState() {
|
||||
L.libEpoch++;
|
||||
L.currentPage = 0;
|
||||
@@ -768,6 +813,8 @@ export function _applyLibFiltersToParams(params) {
|
||||
if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(','));
|
||||
if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics));
|
||||
if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(','));
|
||||
// Which instrument's tuning the `tunings` filter + the tuning sort read.
|
||||
if (_libraryInstrument() !== 'guitar-lead') params.set('instrument', _libraryInstrument());
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -851,6 +898,7 @@ async function _renderTuningList() {
|
||||
c.innerHTML = '<div class="text-xs text-gray-500 px-2">Loading...</div>';
|
||||
try {
|
||||
const params = _applyLibraryProviderToParams(new URLSearchParams());
|
||||
params.set('instrument', _libraryInstrument());
|
||||
const resp = await fetch(`/api/library/tuning-names?${params}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
@@ -869,6 +917,11 @@ async function _renderTuningList() {
|
||||
fetchError = e.message || 'request failed';
|
||||
}
|
||||
}
|
||||
// NAME the perspective: silent instrument-following is the original bug in
|
||||
// a new place — the user must be able to see which instrument these
|
||||
// tunings describe.
|
||||
const labelEl = document.getElementById('filter-tunings-label');
|
||||
if (labelEl) labelEl.textContent = `Tuning (${_libraryInstrumentLabel()})`;
|
||||
c.innerHTML = '';
|
||||
if (fetchError) {
|
||||
c.innerHTML = `<div class="text-xs text-red-400 px-2">Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.</div>`;
|
||||
@@ -894,10 +947,17 @@ async function _renderTuningList() {
|
||||
const checked = _libFilters.tunings.includes(val);
|
||||
const row = document.createElement('label');
|
||||
row.className = 'tuning-row';
|
||||
// Be honest about the fallback: songs with no bass arrangement borrow
|
||||
// the guitar chart's tuning, and that must be visible rather than
|
||||
// presented as a measured bass tuning.
|
||||
const inferred = t.inferred_count || 0;
|
||||
if (inferred) {
|
||||
row.title = `${inferred} of ${t.count} inferred from the guitar chart (no bass arrangement)`;
|
||||
}
|
||||
row.innerHTML =
|
||||
`<input type="checkbox" ${checked ? 'checked' : ''} class="rounded border-gray-600 bg-dark-700 text-accent">` +
|
||||
`<span class="flex-1">${esc(label)}</span>` +
|
||||
`<span class="tuning-count">${t.count}</span>`;
|
||||
`<span class="tuning-count">${t.count}${inferred ? ` (${inferred}~)` : ''}</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.onchange = () => {
|
||||
const i = _libFilters.tunings.indexOf(val);
|
||||
@@ -1244,6 +1304,10 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
|
||||
const duration = song.duration ? formatTime(song.duration) : '';
|
||||
const tuningRaw = song.tuning || song.tuning_name || '';
|
||||
const tuning = displayTuningName(tuningRaw);
|
||||
// The BADGE follows the player's instrument; `tuning` above stays the
|
||||
// song's guitar-derived tuning because the retune action below rewrites
|
||||
// the chart to E Standard and must not key on the bass part.
|
||||
const tuningBadge = displayTuningName(_rowTuningRaw(song));
|
||||
const artUrl = _librarySongArtUrl(song, providerId);
|
||||
const isLocalProvider = _isLocalLibraryProvider(providerId);
|
||||
const isSloppak = song.format === 'sloppak';
|
||||
@@ -1299,7 +1363,7 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-1.5 mt-3 text-xs">
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${tuningBadge ? `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
@@ -1470,6 +1534,9 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
|
||||
const duration = song.duration ? formatTime(song.duration) : '';
|
||||
const tuningRaw = song.tuning || song.tuning_name || '';
|
||||
const tuning = displayTuningName(tuningRaw);
|
||||
// Badge follows the player's instrument; the retune action below
|
||||
// keeps operating on the song's guitar-derived tuning.
|
||||
const tuningBadge = displayTuningName(_rowTuningRaw(song));
|
||||
const isLocalProvider = _isLocalLibraryProvider(providerId);
|
||||
const isSloppak = song.format === 'sloppak';
|
||||
const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd &&
|
||||
@@ -1496,8 +1563,8 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
|
||||
{ const _nm = _getArrangementNamingMode();
|
||||
for (const arrangement of (song.arrangements || []))
|
||||
html += _arrangementBadgeHtml(arrangement, _nm); }
|
||||
if (tuning)
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
|
||||
if (tuningBadge)
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
|
||||
+12
-3
@@ -638,9 +638,18 @@ export let artAbortController = null;
|
||||
export async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
// can't hijack the next song's end. The queue signals a play it is DRIVING
|
||||
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
|
||||
// band). The out-of-band one exists because plugin playSong wrappers forward
|
||||
// only (filename, arrangement) and drop the options object — with just the
|
||||
// in-band flag, the queue cleared itself the instant its first song played
|
||||
// and a gig never advanced. Consume the flag whether or not we go on to clear,
|
||||
// so it can't leak into a later manual play.
|
||||
const _pq = window.feedBack && window.feedBack.playQueue;
|
||||
const _queueDriven = (options && options.fromQueue)
|
||||
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
|
||||
if (!_queueDriven && _pq) {
|
||||
_pq.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
|
||||
//
|
||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||
@@ -29,6 +29,8 @@
|
||||
// phase 2; the localStorage side is best-effort merge after server
|
||||
// success. Failures are reported, never silenced.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
export async function exportSettings() {
|
||||
const status = document.getElementById('backup-status');
|
||||
status.textContent = 'Exporting...';
|
||||
@@ -66,14 +68,7 @@ export async function exportSettings() {
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user