mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 12:04:29 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c606e0b4c |
@@ -63,18 +63,11 @@ 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
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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
|
||||
+501
-639
File diff suppressed because it is too large
Load Diff
@@ -400,14 +400,6 @@ 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.
|
||||
|
||||
@@ -153,14 +153,6 @@ 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.
|
||||
@@ -200,7 +192,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, 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -256,7 +248,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 `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.
|
||||
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.
|
||||
|
||||
## First-Party Management Plugins
|
||||
|
||||
@@ -303,9 +295,8 @@ 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,57 +499,6 @@ 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,10 +60,6 @@ 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.
|
||||
|
||||
+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, encoding="utf-8")
|
||||
filepath.write_text(xml_str)
|
||||
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, encoding="utf-8")
|
||||
filepath.write_text(xml_str)
|
||||
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, encoding="utf-8")
|
||||
filepath.write_text(xml_str)
|
||||
output_files.append(str(filepath))
|
||||
|
||||
# Keys/piano tracks additionally get a standard-notation sidecar
|
||||
|
||||
+6
-76
@@ -72,59 +72,15 @@ 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.
|
||||
|
||||
``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.
|
||||
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.
|
||||
"""
|
||||
audio_files = [
|
||||
n for n in zf.namelist()
|
||||
@@ -159,32 +115,6 @@ 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)
|
||||
|
||||
+26
-105
@@ -17,12 +17,7 @@ import threading
|
||||
from typing import ClassVar
|
||||
|
||||
import appstate
|
||||
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 metadata_db import MetadataDB, _tuning_group_key_sql
|
||||
from routers import art as art_router
|
||||
|
||||
import logging
|
||||
@@ -44,6 +39,9 @@ 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"
|
||||
@@ -71,43 +69,28 @@ class LocalLibraryProvider:
|
||||
def query_stats(self, **kwargs) -> dict:
|
||||
return self._db.query_stats(**kwargs)
|
||||
|
||||
def tuning_names(self, instrument: str = DEFAULT_PERSPECTIVE) -> dict:
|
||||
def tuning_names(self) -> 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 {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}, '') != '' "
|
||||
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, '') != '' "
|
||||
"GROUP BY gkey COLLATE NOCASE "
|
||||
f"ORDER BY ABS(COALESCE(MIN({sort_sql}), 0)), "
|
||||
f"COALESCE(MIN({sort_sql}), 0) ASC, "
|
||||
f"{name_sql} COLLATE NOCASE"
|
||||
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
|
||||
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
|
||||
"tuning_name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return {
|
||||
"instrument": instrument,
|
||||
"tunings": [
|
||||
{"name": name, "key": gkey, "offsets": offs or "",
|
||||
"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
|
||||
"sort_key": int(sk or 0), "count": count}
|
||||
for name, gkey, sk, count, offs in rows
|
||||
],
|
||||
}
|
||||
|
||||
@@ -347,16 +330,9 @@ 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, 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()
|
||||
def _filter_kwargs(self) -> dict:
|
||||
return _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
|
||||
@@ -364,31 +340,28 @@ class SmartCollectionProvider:
|
||||
return self._rules.get("sort") or fallback
|
||||
|
||||
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
|
||||
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
|
||||
naming_mode="legacy", **_ignore):
|
||||
return self._local._db.query_page(
|
||||
page=page, size=size, sort=self._sort(sort), direction=direction,
|
||||
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
|
||||
naming_mode=naming_mode, **self._filter_kwargs())
|
||||
|
||||
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy",
|
||||
instrument="", playable_from_pitch=None, **_ignore):
|
||||
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
|
||||
return self._local._db.query_artists(
|
||||
letter=letter, page=page, size=size, naming_mode=naming_mode,
|
||||
**self._filter_kwargs(instrument, playable_from_pitch))
|
||||
**self._filter_kwargs())
|
||||
|
||||
def query_albums(self, *, page=0, size=120, naming_mode="legacy",
|
||||
instrument="", playable_from_pitch=None, **_ignore):
|
||||
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
|
||||
return self._local._db.query_albums(
|
||||
page=page, size=size, naming_mode=naming_mode,
|
||||
**self._filter_kwargs(instrument, playable_from_pitch))
|
||||
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
|
||||
|
||||
def query_stats(self, *, sort="artist", want_sort_letters=False,
|
||||
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
|
||||
naming_mode="legacy", **_ignore):
|
||||
return self._local._db.query_stats(
|
||||
sort=self._sort(sort), want_sort_letters=want_sort_letters,
|
||||
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
|
||||
naming_mode=naming_mode, **self._filter_kwargs())
|
||||
|
||||
def tuning_names(self, instrument: str = "guitar"):
|
||||
return self._local.tuning_names(instrument=_normalize_instrument(instrument))
|
||||
def tuning_names(self):
|
||||
return self._local.tuning_names()
|
||||
|
||||
async def get_art(self, song_id: str):
|
||||
return await self._local.get_art(song_id)
|
||||
@@ -417,10 +390,7 @@ 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 = "",
|
||||
instrument: str = "", tuning_match: str = "",
|
||||
playable_offsets: str = "", playable_instrument: str = "",
|
||||
playable_string_count: str = "") -> dict:
|
||||
has_lyrics: str = "", tunings: str = "") -> dict:
|
||||
fmt = format if format in ("archive", "sloppak", "loose") else ""
|
||||
return {
|
||||
"q": q,
|
||||
@@ -434,58 +404,9 @@ 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(
|
||||
|
||||
+1
-21
@@ -225,18 +225,13 @@ 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 —
|
||||
plus `bass_tuning_offsets` from the first bass arrangement (None when the
|
||||
folder has none), so the index can carry both tunings.
|
||||
bass) — picking the guitar tuning when both bass and lead are present.
|
||||
"""
|
||||
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
|
||||
@@ -274,10 +269,6 @@ 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,
|
||||
@@ -290,8 +281,6 @@ 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
|
||||
|
||||
|
||||
@@ -423,14 +412,6 @@ 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:
|
||||
@@ -446,7 +427,6 @@ 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,
|
||||
|
||||
+36
-344
@@ -25,8 +25,6 @@ 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")
|
||||
|
||||
@@ -36,113 +34,17 @@ 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.
|
||||
#
|
||||
# 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 ""
|
||||
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")
|
||||
|
||||
|
||||
# ── 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.
|
||||
|
||||
@@ -479,18 +381,7 @@ class MetadataDB:
|
||||
tuning_offsets TEXT DEFAULT '',
|
||||
genre TEXT DEFAULT '',
|
||||
track_number 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
|
||||
disc INTEGER
|
||||
)
|
||||
""")
|
||||
# Idempotent migrations for installs that predate each column.
|
||||
@@ -517,32 +408,6 @@ 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)
|
||||
@@ -802,16 +667,6 @@ 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
|
||||
@@ -2550,14 +2405,10 @@ 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), (position IS NULL), position, name COLLATE NOCASE"
|
||||
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
@@ -2715,9 +2566,7 @@ 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.tuning_offsets,
|
||||
s.bass_tuning_name, s.bass_tuning_offsets,
|
||||
s.rhythm_tuning_name, s.rhythm_tuning_offsets
|
||||
(s.filename IS NULL) AS dead
|
||||
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""",
|
||||
@@ -2729,17 +2578,6 @@ 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:
|
||||
@@ -2767,9 +2605,7 @@ 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, "
|
||||
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
|
||||
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
|
||||
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
|
||||
"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()
|
||||
@@ -2779,16 +2615,8 @@ 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}
|
||||
@@ -2882,30 +2710,6 @@ 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
|
||||
@@ -3006,39 +2810,16 @@ 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, "
|
||||
f"{pcols} "
|
||||
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
|
||||
"FROM songs WHERE filename = ?", (cache_key,)
|
||||
).fetchone()
|
||||
if row and row[0] == mtime and row[1] == size and row[2]:
|
||||
out = {
|
||||
return {
|
||||
"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 [],
|
||||
@@ -3050,15 +2831,6 @@ 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):
|
||||
@@ -3066,9 +2838,8 @@ 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, "
|
||||
+ ", ".join(self._PERSPECTIVE_COLS) + ") "
|
||||
"VALUES (" + ", ".join(["?"] * (20 + len(self._PERSPECTIVE_COLS))) + ")",
|
||||
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(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", [])),
|
||||
@@ -3081,14 +2852,7 @@ class MetadataDB:
|
||||
meta.get("tuning_offsets", "") or "",
|
||||
meta.get("genre", "") or "",
|
||||
meta.get("track_number"),
|
||||
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]),
|
||||
meta.get("disc")),
|
||||
)
|
||||
self.conn.commit()
|
||||
# A song's identity may have changed → the grouping read-model is stale.
|
||||
@@ -3568,8 +3332,6 @@ 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
|
||||
@@ -3676,8 +3438,7 @@ 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,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
where += ifrag
|
||||
params += iparams
|
||||
return where, params
|
||||
@@ -3689,9 +3450,7 @@ class MetadataDB:
|
||||
stems_lacks: list[str] | None = None,
|
||||
has_lyrics: int | None = None,
|
||||
tunings: list[str] | None = None,
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[str, list]:
|
||||
naming_mode: str = "legacy") -> 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
|
||||
@@ -3834,32 +3593,10 @@ 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. `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)} "
|
||||
# tunings still match by name.
|
||||
where += (f" AND {_tuning_group_key_sql(alias)} "
|
||||
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
|
||||
@@ -4127,9 +3864,7 @@ class MetadataDB:
|
||||
genre: list[str] | None = None,
|
||||
after: str | None = None,
|
||||
group: bool = False,
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
|
||||
naming_mode: str = "legacy") -> 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).
|
||||
@@ -4158,9 +3893,7 @@ 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, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
include_intrinsic=not group,
|
||||
naming_mode=naming_mode, include_intrinsic=not group,
|
||||
)
|
||||
ifrag, iparams = "", []
|
||||
if group:
|
||||
@@ -4169,14 +3902,12 @@ 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,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
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
|
||||
@@ -4210,15 +3941,11 @@ 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": (
|
||||
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"
|
||||
"(COALESCE(tuning_name, '') = '') ASC, "
|
||||
"ABS(COALESCE(tuning_sort_key, 0)), "
|
||||
"COALESCE(tuning_sort_key, 0) ASC, "
|
||||
"COALESCE(tuning_name, '') COLLATE NOCASE"
|
||||
),
|
||||
# Year sort (feedBack#128). Empty-year rows pushed to the
|
||||
# bottom for both directions; otherwise CAST so '2010' >
|
||||
@@ -4311,9 +4038,7 @@ class MetadataDB:
|
||||
|
||||
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
|
||||
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
|
||||
"tuning_name, tuning_offsets, bass_tuning_name, bass_tuning_offsets, "
|
||||
"rhythm_tuning_name, rhythm_tuning_offsets "
|
||||
"FROM songs ")
|
||||
"tuning_name, 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:
|
||||
@@ -4346,30 +4071,8 @@ 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
|
||||
@@ -4466,7 +4169,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.bass_tuning_name, m.bass_tuning_offsets "
|
||||
"m.tuning_name, m.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",
|
||||
@@ -4487,7 +4190,6 @@ 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 = "",
|
||||
@@ -4502,9 +4204,7 @@ class MetadataDB:
|
||||
stems_lacks: list[str] | None = None,
|
||||
has_lyrics: int | None = None,
|
||||
tunings: list[str] | None = None,
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
|
||||
naming_mode: str = "legacy") -> 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,
|
||||
@@ -4512,7 +4212,6 @@ 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
|
||||
@@ -4548,7 +4247,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, bass_tuning_name "
|
||||
f"format, stem_count, stem_ids, tuning_name "
|
||||
f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
|
||||
song_params
|
||||
).fetchall()
|
||||
@@ -4581,7 +4280,6 @@ 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]),
|
||||
@@ -4603,8 +4301,7 @@ class MetadataDB:
|
||||
stems_has=None, stems_lacks=None,
|
||||
has_lyrics=None, tunings=None, mastery=None,
|
||||
match_states=None, genre=None,
|
||||
naming_mode="legacy", instrument=DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch=None, page=0, size=120):
|
||||
naming_mode="legacy", 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
|
||||
@@ -4616,8 +4313,7 @@ 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, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
naming_mode=naming_mode,
|
||||
)
|
||||
awhere = where + " AND album IS NOT NULL AND album != ''"
|
||||
total = self.conn.execute(
|
||||
@@ -4648,9 +4344,7 @@ class MetadataDB:
|
||||
sort: str = "artist",
|
||||
want_sort_letters: bool = False,
|
||||
group: bool = False,
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> dict:
|
||||
naming_mode: str = "legacy") -> 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.
|
||||
@@ -4677,8 +4371,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, match_states=match_states,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
naming_mode=naming_mode,
|
||||
include_intrinsic=not group,
|
||||
)
|
||||
if group:
|
||||
@@ -4690,8 +4383,7 @@ 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,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||
where += mfrag
|
||||
params += mparams
|
||||
|
||||
+2
-436
@@ -1,6 +1,6 @@
|
||||
"""MIDI file import — list tracks and convert tracks to sloppak payloads.
|
||||
|
||||
Three parallel flows live here:
|
||||
Two parallel flows live here:
|
||||
|
||||
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
|
||||
filters channel-9 out and emits a standard guitar-style arrangement that
|
||||
@@ -11,19 +11,12 @@ Three parallel flows live here:
|
||||
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
|
||||
manifest's `drum_tab:` key.
|
||||
|
||||
- **Lyrics path** (`extract_midi_lyrics`): reads SMF Lyric (0x05) meta events
|
||||
(with a Text-event fallback on vocal-ish tracks, covering karaoke `.kar`
|
||||
files) and emits the `lyrics.json` / `vocal_pitch.json` sidecar payloads
|
||||
documented in feedpak-spec §7.1 / §7.2, ready to drop alongside the
|
||||
manifest's `lyrics:` / `lyrics_source:` / `vocal_pitch:` keys.
|
||||
|
||||
The editor's track picker uses the first two for the +Drums and +Keys modals.
|
||||
The editor's track picker uses both for the +Drums and +Keys modals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from bisect import bisect_right
|
||||
from collections import deque
|
||||
from typing import Callable
|
||||
@@ -784,430 +777,3 @@ def convert_drum_track_from_midi(
|
||||
],
|
||||
"hits": out_hits,
|
||||
}
|
||||
|
||||
|
||||
# ── Lyrics + vocal-melody extraction ─────────────────────────────────────────
|
||||
|
||||
# Vocal-track detection, mirroring the idiom in `lib/gp2rs_gpx.py`'s
|
||||
# `_is_vocal_track` (GM voice/choir/lead-voice programs + name keywords).
|
||||
# Kept as a local copy because that helper consumes gp2rs_gpx's own GP track
|
||||
# dicts, not raw MIDI tracks. "melody" is added to the name hints: karaoke
|
||||
# MIDIs commonly label the sung line "Melody" rather than "Vocals".
|
||||
_VOCAL_MIDI_PROGRAMS = {52, 53, 54, 85, 86, 87} # Choir Aahs, Voice Oohs, Synth Voice, Lead 5-7 (voice)
|
||||
_VOCAL_NAME_HINTS = ("vocal", "voice", "vox", "sing", "lyric", "choir", "melody")
|
||||
|
||||
# A dedicated karaoke *text* track (SMF 0x01 Text events, `.kar` convention)
|
||||
# is usually noteless and named "Words" or "Soft Karaoke" — names the vocal
|
||||
# hints above don't catch. Only the Text-event fallback consults this wider
|
||||
# set; note-track detection sticks to the gp2rs_gpx idiom.
|
||||
_LYRIC_TEXT_TRACK_HINTS = _VOCAL_NAME_HINTS + ("words", "karaoke")
|
||||
|
||||
# A lyric event pairs with a vocal note-on when their onsets sit within this
|
||||
# window. Karaoke files place the lyric event at (or a hair before) the
|
||||
# note-on tick, so real matches are ~0; the window only absorbs sloppy
|
||||
# authoring, and staying well under a typical syllable gap keeps a melisma's
|
||||
# extra notes from being stolen by the next syllable.
|
||||
_LYRIC_PAIR_TOLERANCE_S = 0.30
|
||||
|
||||
# Duration bounds for lyric entries with no pairable note (spoken lines,
|
||||
# lyrics-only files). "Until the next lyric event" is the natural display
|
||||
# duration, capped so a verse-final syllable before a long instrumental
|
||||
# break doesn't linger on screen, and floored so simultaneous/out-of-order
|
||||
# events can't produce a zero or negative duration.
|
||||
_UNPAIRED_LYRIC_MAX_D = 2.0
|
||||
_UNPAIRED_LYRIC_MIN_D = 0.1
|
||||
|
||||
# Leading/word/trailing whitespace splitter for lyric tokens. DOTALL so
|
||||
# embedded newlines land in a group rather than killing the match.
|
||||
_LYRIC_TOKEN_RE = re.compile(r"^(\s*)(.*?)(\s*)$", re.S)
|
||||
|
||||
|
||||
def _scan_tracks_for_lyrics(midi: mido.MidiFile) -> list[dict]:
|
||||
"""One pass per track collecting the raw material `extract_midi_lyrics`
|
||||
needs: name, per-channel programs, melodic (non-drum) notes with their
|
||||
on/off ticks, and Lyric/Text meta events.
|
||||
|
||||
Each item: {name, channel_programs: {ch: program}, notes:
|
||||
[(start_tick, end_tick, pitch, channel)], lyric_events: [(tick, text)],
|
||||
text_events: [(tick, text)]}. Note pairing uses the same FIFO
|
||||
note_on/note_off matching as the keys converter so retriggers don't
|
||||
cross-wire durations.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for track in midi.tracks:
|
||||
name = ""
|
||||
channel_programs: dict[int, int] = {}
|
||||
lyric_events: list[tuple[int, str]] = []
|
||||
text_events: list[tuple[int, str]] = []
|
||||
notes: list[tuple[int, int, int, int]] = []
|
||||
active: dict[tuple[int, int], deque[int]] = {}
|
||||
abs_tick = 0
|
||||
for msg in track:
|
||||
abs_tick += msg.time
|
||||
if msg.type == "track_name" and not name:
|
||||
name = msg.name or ""
|
||||
elif msg.type == "lyrics":
|
||||
lyric_events.append((abs_tick, msg.text or ""))
|
||||
elif msg.type == "text":
|
||||
text_events.append((abs_tick, msg.text or ""))
|
||||
elif msg.type == "program_change":
|
||||
ch = int(getattr(msg, "channel", -1))
|
||||
if ch != 9 and ch not in channel_programs:
|
||||
channel_programs[ch] = int(msg.program)
|
||||
elif msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
|
||||
ch = int(getattr(msg, "channel", -1))
|
||||
if ch == 9:
|
||||
continue
|
||||
active.setdefault((ch, int(msg.note)), deque()).append(abs_tick)
|
||||
elif msg.type == "note_off" or (
|
||||
msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
|
||||
):
|
||||
ch = int(getattr(msg, "channel", -1))
|
||||
pitch = int(msg.note)
|
||||
stack = active.get((ch, pitch))
|
||||
if not stack:
|
||||
continue
|
||||
start_tick = stack.popleft()
|
||||
if not stack:
|
||||
active.pop((ch, pitch), None)
|
||||
notes.append((start_tick, abs_tick, pitch, ch))
|
||||
# Close anything left hanging at end-of-track, mirroring the keys
|
||||
# converter's end-of-track sweep.
|
||||
for (ch, pitch), starts in active.items():
|
||||
for start_tick in starts:
|
||||
notes.append((start_tick, abs_tick, pitch, ch))
|
||||
notes.sort(key=lambda n: n[0])
|
||||
out.append({
|
||||
"name": name,
|
||||
"channel_programs": channel_programs,
|
||||
"notes": notes,
|
||||
"lyric_events": lyric_events,
|
||||
"text_events": text_events,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _name_matches(name: str, hints: tuple[str, ...]) -> bool:
|
||||
name_l = (name or "").lower()
|
||||
return any(h in name_l for h in hints)
|
||||
|
||||
|
||||
def _normalize_lyric_tokens(events: list[tuple[int, str]]) -> list[dict]:
|
||||
"""Turn raw lyric/text meta events into clean syllable tokens.
|
||||
|
||||
Handles both encodings seen in the wild:
|
||||
|
||||
- **`.kar` / karaoke convention**: `/` prefix = new line, `\\` prefix =
|
||||
new paragraph (both mean "the previous syllable ended a line"),
|
||||
`-` suffix = syllable joins the next one, `@`-prefixed tokens are
|
||||
file metadata (`@KMIDI`, `@T<title>`, ...) and are dropped.
|
||||
- **Plain Lyric-event convention**: word boundaries carried by leading
|
||||
or trailing spaces; line breaks carried by embedded CR/LF.
|
||||
|
||||
Each token: {tick, word, lead_ws, trail_ws, line_end}. Spacing-only and
|
||||
newline-only events don't emit a token — they fold their meaning
|
||||
(word-break / line-end) onto the previous one.
|
||||
"""
|
||||
toks: list[dict] = []
|
||||
for tick, raw in events:
|
||||
text = "" if raw is None else str(raw)
|
||||
if not text:
|
||||
continue
|
||||
if text.lstrip().startswith(("@", "%")):
|
||||
# .kar metadata / sequencer directives, not sung text.
|
||||
continue
|
||||
kar_break = text[0] in ("/", "\\")
|
||||
if kar_break:
|
||||
text = text[1:]
|
||||
m = _LYRIC_TOKEN_RE.match(text)
|
||||
head, body, tail = m.group(1), m.group(2), m.group(3)
|
||||
nl_before = ("\n" in head) or ("\r" in head)
|
||||
nl_after = ("\n" in tail) or ("\r" in tail)
|
||||
if "\n" in body or "\r" in body:
|
||||
# Rare multi-line event: keep it one token, treat the break as
|
||||
# trailing so the line ends after this token.
|
||||
body = re.sub(r"[\r\n]+", " ", body).strip()
|
||||
nl_after = True
|
||||
if (kar_break or nl_before) and toks:
|
||||
toks[-1]["line_end"] = True
|
||||
if not body:
|
||||
# Pure spacing/newline token: fold onto the previous syllable.
|
||||
if nl_after and toks:
|
||||
toks[-1]["line_end"] = True
|
||||
if toks:
|
||||
toks[-1]["trail_ws"] = True
|
||||
continue
|
||||
toks.append({
|
||||
"tick": tick,
|
||||
"word": body,
|
||||
"lead_ws": bool(head),
|
||||
"trail_ws": bool(tail),
|
||||
"line_end": nl_after,
|
||||
})
|
||||
return toks
|
||||
|
||||
|
||||
def _apply_word_conventions(toks: list[dict]) -> list[str]:
|
||||
"""Map tokens to spec §7.1 `w` strings: trailing ``-`` joins to the next
|
||||
syllable, trailing ``+`` ends a line.
|
||||
|
||||
Which join convention the source used is detected per stream:
|
||||
|
||||
- Any token already carrying a ``-`` suffix → the stream is
|
||||
hyphen-delimited (`.kar` style); those suffixes are the spec's own
|
||||
join marker and pass through untouched.
|
||||
- Otherwise, if the stream carries any spacing at all → space-delimited:
|
||||
a token with no trailing space followed by a token with no leading
|
||||
space is a mid-word syllable and gains a ``-``.
|
||||
- No hyphens and no spacing anywhere → the tokens are whole words
|
||||
(common for Text-event lyrics); no joins are synthesized.
|
||||
"""
|
||||
has_hyphens = any(t["word"].endswith("-") for t in toks)
|
||||
has_spacing = any(t["lead_ws"] or t["trail_ws"] for t in toks)
|
||||
words: list[str] = []
|
||||
for i, tk in enumerate(toks):
|
||||
w = tk["word"]
|
||||
nxt = toks[i + 1] if i + 1 < len(toks) else None
|
||||
if tk["line_end"]:
|
||||
# A join can't cross a line break — the line marker wins.
|
||||
if w.endswith("-"):
|
||||
w = w[:-1]
|
||||
if w and not w.endswith("+"):
|
||||
w += "+"
|
||||
elif nxt is not None and not has_hyphens and has_spacing:
|
||||
if not tk["trail_ws"] and not nxt["lead_ws"] and not w.endswith("-"):
|
||||
w += "-"
|
||||
words.append(w)
|
||||
return words
|
||||
|
||||
|
||||
def _select_vocal_notes(
|
||||
scans: list[dict],
|
||||
lyric_track_index: int,
|
||||
midi_type: int,
|
||||
) -> tuple[int, list[tuple[int, int, int, int]]] | None:
|
||||
"""Pick the note pool the lyric syllables should be pitch-paired with.
|
||||
|
||||
Returns ``(track_index, notes)`` or ``None`` when no vocal melody is
|
||||
identifiable (→ lyrics-only import). Selection order:
|
||||
|
||||
1. The lyric-carrying track itself, when it has notes:
|
||||
- channels with a vocal GM program → only those channels' notes
|
||||
(isolates the sung line inside a format-0 everything-in-one-track
|
||||
file);
|
||||
- vocal-ish track name → all its non-drum notes;
|
||||
- SMF type 1/2 with neither → still trusted: a track that interleaves
|
||||
per-syllable Lyric events with its own notes *is* the karaoke
|
||||
melody by construction. Format-0 files don't get this benefit of
|
||||
the doubt — there the single track holds every instrument, so
|
||||
without a vocal program/name there is no way to isolate the melody
|
||||
and we fall back to lyrics-only.
|
||||
2. Otherwise (dedicated noteless "Words" track), the vocal-ish track —
|
||||
by name hint or vocal GM program, mirroring gp2rs_gpx — with the
|
||||
most notes; within it, vocal-program channels only when present.
|
||||
"""
|
||||
def _vocal_channels(scan: dict) -> set[int]:
|
||||
return {
|
||||
ch for ch, prog in scan["channel_programs"].items()
|
||||
if prog in _VOCAL_MIDI_PROGRAMS
|
||||
}
|
||||
|
||||
def _pool(scan: dict) -> list[tuple[int, int, int, int]]:
|
||||
chans = _vocal_channels(scan)
|
||||
if chans:
|
||||
return [n for n in scan["notes"] if n[3] in chans]
|
||||
return scan["notes"]
|
||||
|
||||
src = scans[lyric_track_index]
|
||||
if src["notes"]:
|
||||
if _vocal_channels(src) or _name_matches(src["name"], _VOCAL_NAME_HINTS):
|
||||
return lyric_track_index, _pool(src)
|
||||
if midi_type != 0:
|
||||
return lyric_track_index, src["notes"]
|
||||
return None
|
||||
|
||||
best: tuple[int, list] | None = None
|
||||
for i, scan in enumerate(scans):
|
||||
if not scan["notes"]:
|
||||
continue
|
||||
if not (_vocal_channels(scan)
|
||||
or _name_matches(scan["name"], _VOCAL_NAME_HINTS)):
|
||||
continue
|
||||
pool = _pool(scan)
|
||||
if pool and (best is None or len(pool) > len(best[1])):
|
||||
best = (i, pool)
|
||||
return best
|
||||
|
||||
|
||||
def extract_midi_lyrics(midi_path: str, audio_offset: float = 0.0) -> dict | None:
|
||||
"""Extract lyrics (and, when pairable, the vocal melody) from a `.mid`.
|
||||
|
||||
Returns ``None`` when the file carries no usable lyric events — callers
|
||||
then change nothing, leaving any existing manifest keys and sidecar
|
||||
files untouched. Otherwise returns::
|
||||
|
||||
{
|
||||
"lyrics": [{"t": float, "d": float, "w": str}, ...],
|
||||
"lyrics_source": "authored",
|
||||
"vocal_pitch": {"version": 1,
|
||||
"notes": [{"t", "d", "midi"}, ...]} | None,
|
||||
}
|
||||
|
||||
``lyrics`` is the feedpak `lyrics.json` payload (spec §7.1: flat list,
|
||||
no version field; ``w`` uses trailing ``-`` for syllable joins and
|
||||
trailing ``+`` for line ends). ``vocal_pitch`` is the `vocal_pitch.json`
|
||||
payload (spec §7.2, same shape as gp2rs_gpx's
|
||||
``convert_vocal_track_to_pitch_sidecar`` and the lyrics-karaoke
|
||||
plugin's ``_persist_pitch``) — ``None`` when no vocal note track could
|
||||
be identified, in which case the caller writes `lyrics.json` only.
|
||||
``lyrics_source`` is always ``"authored"`` (spec §7.1 vocabulary):
|
||||
lyric meta events are chart-author data, not machine transcription.
|
||||
|
||||
Callers assembling a pack write ``lyrics.json`` /
|
||||
``vocal_pitch.json`` and set the manifest ``lyrics`` /
|
||||
``lyrics_source`` / ``vocal_pitch`` keys — and should do so only for
|
||||
keys not already present, so an import never clobbers lyrics that
|
||||
arrived from another source.
|
||||
|
||||
Sourcing rules:
|
||||
|
||||
- Lyric text comes from SMF Lyric (0x05) meta events — the track with
|
||||
the most of them wins when several carry some. When the file has
|
||||
none at all, Text (0x01) events are accepted as a fallback, but only
|
||||
from a vocal-ish track (gp2rs_gpx-style name/program detection,
|
||||
widened with "words"/"karaoke" for `.kar` text tracks) — Text events
|
||||
elsewhere are copyright notices / markers, not lyrics.
|
||||
- `.kar` conventions are normalized (see ``_normalize_lyric_tokens`` /
|
||||
``_apply_word_conventions``): ``/`` and ``\\`` line-break prefixes
|
||||
become the spec's ``+`` suffix on the previous syllable, ``@``
|
||||
metadata tokens are dropped, ``-`` hyphen joins pass through.
|
||||
- Each syllable is paired with the vocal note (see
|
||||
``_select_vocal_notes``) whose onset falls within
|
||||
``_LYRIC_PAIR_TOLERANCE_S`` of the lyric event, greedily in time
|
||||
order, one note per syllable. Paired syllables snap ``t``/``d`` to
|
||||
the note (the authored melody is timing-authoritative, and keeps
|
||||
`lyrics.json` and `vocal_pitch.json` mirrored per §7.2); a melisma's
|
||||
extra notes are skipped. Unpaired syllables (talkies) keep the lyric
|
||||
event's own time and run until the next syllable, clamped to
|
||||
[``_UNPAIRED_LYRIC_MIN_D``, ``_UNPAIRED_LYRIC_MAX_D``] — they appear
|
||||
in ``lyrics`` only, which spec §7.2 explicitly allows
|
||||
(`vocal_pitch.notes` MAY be shorter than `lyrics.json`).
|
||||
|
||||
``audio_offset`` (seconds) shifts every emitted time, same handle as
|
||||
the keys/drums converters. Tempo-map scope per SMF type also matches
|
||||
them (type 2 reads only the involved track's tempo events).
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
raise ValueError(f"audio_offset must be a finite number, got {audio_offset!r}")
|
||||
|
||||
midi = mido.MidiFile(midi_path)
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
scans = _scan_tracks_for_lyrics(midi)
|
||||
|
||||
# ── choose the lyric event stream ────────────────────────────────────
|
||||
lyric_idx = -1
|
||||
best_count = 0
|
||||
for i, scan in enumerate(scans):
|
||||
if len(scan["lyric_events"]) > best_count:
|
||||
lyric_idx = i
|
||||
best_count = len(scan["lyric_events"])
|
||||
if lyric_idx >= 0:
|
||||
toks = _normalize_lyric_tokens(scans[lyric_idx]["lyric_events"])
|
||||
else:
|
||||
# Text-event fallback: vocal-ish tracks only (plus .kar "Words" /
|
||||
# "Soft Karaoke" text tracks). Normalize before counting so a track
|
||||
# of @-metadata can't outscore a real lyric track.
|
||||
toks = []
|
||||
for i, scan in enumerate(scans):
|
||||
if not scan["text_events"]:
|
||||
continue
|
||||
vocal_prog = any(
|
||||
p in _VOCAL_MIDI_PROGRAMS
|
||||
for p in scan["channel_programs"].values()
|
||||
)
|
||||
if not (vocal_prog
|
||||
or _name_matches(scan["name"], _LYRIC_TEXT_TRACK_HINTS)):
|
||||
continue
|
||||
cand = _normalize_lyric_tokens(scan["text_events"])
|
||||
if len(cand) > len(toks):
|
||||
lyric_idx = i
|
||||
toks = cand
|
||||
if lyric_idx < 0 or not toks:
|
||||
return None
|
||||
|
||||
words = _apply_word_conventions(toks)
|
||||
lyric_tick_to_seconds = _build_tick_to_seconds(midi, lyric_idx)
|
||||
|
||||
# ── pick + time the vocal note pool ──────────────────────────────────
|
||||
picked = _select_vocal_notes(scans, lyric_idx, midi_type)
|
||||
vocal_notes: list[dict] = []
|
||||
if picked is not None:
|
||||
note_idx, pool = picked
|
||||
# Type-2 tracks own independent timelines — time the notes through
|
||||
# their own track's tempo scope (same map as the lyric track for
|
||||
# type 0/1, where tempo is merged across tracks anyway).
|
||||
note_tick_to_seconds = (
|
||||
lyric_tick_to_seconds if note_idx == lyric_idx
|
||||
else _build_tick_to_seconds(midi, note_idx)
|
||||
)
|
||||
for start_tick, end_tick, pitch, _ch in pool:
|
||||
t = note_tick_to_seconds(start_tick)
|
||||
vocal_notes.append({
|
||||
"t": t,
|
||||
"d": max(0.0, note_tick_to_seconds(end_tick) - t),
|
||||
"midi": int(pitch),
|
||||
})
|
||||
vocal_notes.sort(key=lambda n: n["t"])
|
||||
|
||||
# ── pair syllables with notes (greedy, time-ordered) ─────────────────
|
||||
entries: list[dict] = [] # {t, d (None until resolved), w, paired}
|
||||
j = 0
|
||||
for tk, w in zip(toks, words):
|
||||
t_lyric = lyric_tick_to_seconds(tk["tick"])
|
||||
while (j < len(vocal_notes)
|
||||
and vocal_notes[j]["t"] < t_lyric - _LYRIC_PAIR_TOLERANCE_S):
|
||||
j += 1
|
||||
if (j < len(vocal_notes)
|
||||
and vocal_notes[j]["t"] <= t_lyric + _LYRIC_PAIR_TOLERANCE_S):
|
||||
note = vocal_notes[j]
|
||||
j += 1
|
||||
entries.append({
|
||||
"t": note["t"], "d": note["d"], "w": w,
|
||||
"midi": note["midi"], "paired": True,
|
||||
})
|
||||
else:
|
||||
entries.append({"t": t_lyric, "d": None, "w": w, "paired": False})
|
||||
|
||||
# Snapping can nudge a paired syllable past an unpaired neighbour;
|
||||
# sort so both sidecars stay chronological for downstream consumers.
|
||||
entries.sort(key=lambda e: e["t"])
|
||||
|
||||
# Unpaired durations: until the next syllable, clamped. Resolved after
|
||||
# the sort so "next" is the true chronological neighbour.
|
||||
for i, e in enumerate(entries):
|
||||
if e["d"] is None:
|
||||
if i + 1 < len(entries):
|
||||
gap = entries[i + 1]["t"] - e["t"]
|
||||
d = min(gap, _UNPAIRED_LYRIC_MAX_D)
|
||||
else:
|
||||
d = _UNPAIRED_LYRIC_MAX_D
|
||||
e["d"] = max(d, _UNPAIRED_LYRIC_MIN_D)
|
||||
|
||||
lyrics_out = [
|
||||
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3), "w": e["w"]}
|
||||
for e in entries
|
||||
]
|
||||
pitch_notes = [
|
||||
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3),
|
||||
"midi": int(e["midi"])}
|
||||
for e in entries if e["paired"]
|
||||
]
|
||||
|
||||
return {
|
||||
"lyrics": lyrics_out,
|
||||
"lyrics_source": "authored",
|
||||
"vocal_pitch": (
|
||||
{"version": 1, "notes": pitch_notes} if pitch_notes else None
|
||||
),
|
||||
}
|
||||
|
||||
+11
-37
@@ -54,20 +54,15 @@ 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, "hand": str|None}, ...]``
|
||||
sorted by time.
|
||||
``[{"t": float, "midi": int, "sus": float}, ...]`` 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). ``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.
|
||||
legacy alias). Entries with malformed fields are skipped.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
|
||||
def _push(t, s, f, sus, hand):
|
||||
def _push(t, s, f, sus):
|
||||
try:
|
||||
t = float(t)
|
||||
midi = int(s) * 24 + int(f)
|
||||
@@ -75,15 +70,11 @@ 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),
|
||||
"hand": hand if hand in ("lh", "rh") else None,
|
||||
})
|
||||
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
|
||||
|
||||
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")),
|
||||
n.get("hand"))
|
||||
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
|
||||
for ch in arr_data.get("chords") or []:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
@@ -92,7 +83,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("hand"))
|
||||
cn.get("sus", cn.get("l")))
|
||||
|
||||
out.sort(key=lambda n: (n["t"], n["midi"]))
|
||||
return out
|
||||
@@ -112,31 +103,14 @@ 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``.
|
||||
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
|
||||
|
||||
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).
|
||||
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 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
|
||||
for group in group_simultaneous(notes):
|
||||
pitches = sorted(n["midi"] for n in group)
|
||||
span = pitches[-1] - pitches[0]
|
||||
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
|
||||
|
||||
+13
-42
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
|
||||
import appstate
|
||||
from library_registry import (
|
||||
_library_filter_args, _normalize_instrument, _sanitize_collection_rules,
|
||||
_library_filter_args, _sanitize_collection_rules,
|
||||
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
|
||||
_unregister_collection_provider,
|
||||
)
|
||||
@@ -52,8 +52,7 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
|
||||
|
||||
|
||||
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
|
||||
"mastery", "match_states", "instrument",
|
||||
"playable_from_pitch")
|
||||
"mastery", "match_states")
|
||||
|
||||
|
||||
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
|
||||
@@ -236,20 +235,9 @@ 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", instrument: str = "",
|
||||
tuning_match: str = "", playable_offsets: str = "",
|
||||
playable_instrument: str = "", playable_string_count: str = ""):
|
||||
naming_mode: str = "legacy"):
|
||||
"""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
|
||||
@@ -282,10 +270,7 @@ 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, instrument=instrument,
|
||||
tuning_match=tuning_match, playable_offsets=playable_offsets,
|
||||
playable_instrument=playable_instrument,
|
||||
playable_string_count=playable_string_count,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||
@@ -307,7 +292,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", instrument: str = ""):
|
||||
provider: str = "local"):
|
||||
"""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)
|
||||
@@ -321,7 +306,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, instrument=instrument,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
return {"albums": albums, "total": total, "page": page, "size": size}
|
||||
@@ -334,9 +319,7 @@ 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", instrument: str = "",
|
||||
tuning_match: str = "", playable_offsets: str = "",
|
||||
playable_instrument: str = "", playable_string_count: str = ""):
|
||||
naming_mode: str = "legacy"):
|
||||
"""Get artists grouped by letter with albums and songs (for tree view)."""
|
||||
size = min(size, 100)
|
||||
library_provider = _get_library_provider(provider)
|
||||
@@ -353,7 +336,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, instrument=instrument,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
return {"artists": artists, "total_artists": total, "page": page, "size": size}
|
||||
@@ -367,10 +350,7 @@ 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",
|
||||
instrument: str = "", tuning_match: str = "",
|
||||
playable_offsets: str = "", playable_instrument: str = "",
|
||||
playable_string_count: str = ""):
|
||||
group: int = 0, naming_mode: str = "legacy"):
|
||||
"""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;
|
||||
@@ -395,10 +375,7 @@ 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, instrument=instrument,
|
||||
tuning_match=tuning_match, playable_offsets=playable_offsets,
|
||||
playable_instrument=playable_instrument,
|
||||
playable_string_count=playable_string_count,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -430,20 +407,14 @@ def library_genres(provider: str = "local"):
|
||||
|
||||
|
||||
@router.get("/api/library/tuning-names")
|
||||
async def list_tuning_names(provider: str = "local", instrument: str = ""):
|
||||
async def list_tuning_names(provider: str = "local"):
|
||||
"""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.
|
||||
|
||||
`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)."""
|
||||
(feedBack#22) — E Standard first, then nearest neighbors."""
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
return await _call_library_provider_async(
|
||||
library_provider, "tuning_names", instrument=_normalize_instrument(instrument))
|
||||
return await _call_library_provider_async(library_provider, "tuning_names")
|
||||
|
||||
|
||||
@router.get("/api/library/practice-suggestions")
|
||||
|
||||
@@ -70,37 +70,6 @@ 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)
|
||||
|
||||
+5
-70
@@ -829,61 +829,9 @@ 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, 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).
|
||||
"""
|
||||
async def get_song_info(filename: str):
|
||||
"""Return song metadata, from cache or by extracting it from the song source."""
|
||||
import asyncio
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
@@ -906,21 +854,8 @@ async def get_song_info(filename: str, stems: int = 0):
|
||||
|
||||
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 await _with_stems(cached)
|
||||
return cached
|
||||
|
||||
# Extract in thread pool
|
||||
def _extract():
|
||||
@@ -928,5 +863,5 @@ async def get_song_info(filename: str, stems: int = 0):
|
||||
appstate.meta_db.put(cache_key, mtime, size, meta)
|
||||
return meta
|
||||
|
||||
meta = await loop.run_in_executor(None, _extract)
|
||||
return await _with_stems(meta)
|
||||
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
|
||||
return meta
|
||||
|
||||
+13
-54
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -144,21 +143,9 @@ 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", 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."""
|
||||
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"):
|
||||
"""Stream song data for the highway renderer over WebSocket."""
|
||||
await websocket.accept()
|
||||
structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8])
|
||||
|
||||
@@ -274,8 +261,9 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
bass_idxs = [
|
||||
i
|
||||
for i, a in enumerate(song.arrangements)
|
||||
if arrangement_is_bass(a)
|
||||
if getattr(a, "path_bass", False)
|
||||
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
|
||||
@@ -380,9 +368,7 @@ 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"],
|
||||
**{k: s[k] for k in ("name", "description") if k in s}})
|
||||
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
|
||||
# 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 = (
|
||||
@@ -576,15 +562,6 @@ 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
|
||||
@@ -608,36 +585,18 @@ 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(_dt_msg)
|
||||
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),
|
||||
})
|
||||
for i in range(0, len(hits_wire), 500):
|
||||
await websocket.send_json({
|
||||
"type": "drum_hits",
|
||||
@@ -1014,7 +973,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), arrangement_is_bass(arr))
|
||||
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
|
||||
_capo = int(getattr(arr, "capo", 0) or 0)
|
||||
|
||||
def _fill_scale_degree(wire: dict, n, t: float) -> None:
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""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)
|
||||
+5
-169
@@ -51,120 +51,6 @@ 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}
|
||||
|
||||
@@ -213,12 +99,9 @@ def _make_scan_executor():
|
||||
)
|
||||
|
||||
|
||||
def background_scan(force: bool = False):
|
||||
def background_scan():
|
||||
"""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).
|
||||
|
||||
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.
|
||||
@@ -238,22 +121,6 @@ def background_scan(force: bool = False):
|
||||
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.
|
||||
@@ -342,15 +209,6 @@ def background_scan(force: bool = False):
|
||||
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"]
|
||||
):
|
||||
@@ -365,9 +223,6 @@ def background_scan(force: bool = False):
|
||||
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
|
||||
@@ -392,9 +247,6 @@ def background_scan(force: bool = False):
|
||||
_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}
|
||||
|
||||
@@ -403,9 +255,6 @@ _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
|
||||
|
||||
|
||||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||||
@@ -416,15 +265,9 @@ _scan_force_next = False
|
||||
_scan_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def kick_scan(force: bool = False) -> bool:
|
||||
def kick_scan() -> 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.
|
||||
|
||||
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
|
||||
@@ -432,10 +275,8 @@ def kick_scan(force: bool = False) -> bool:
|
||||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||
into a single follow-up.
|
||||
"""
|
||||
global _scan_rescan_pending, _scan_thread, _scan_force_next
|
||||
global _scan_rescan_pending, _scan_thread
|
||||
with _scan_kick_lock:
|
||||
if force:
|
||||
_scan_force_next = True
|
||||
if _scan_status["running"]:
|
||||
_scan_rescan_pending = True
|
||||
return False
|
||||
@@ -449,15 +290,10 @@ def kick_scan(force: bool = False) -> bool:
|
||||
|
||||
def _scan_runner():
|
||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||
global _scan_rescan_pending, _scan_force_next
|
||||
global _scan_rescan_pending
|
||||
while True:
|
||||
# Consume the force flag for THIS pass; a forced request queued mid-scan
|
||||
# sets it again for the follow-up.
|
||||
with _scan_kick_lock:
|
||||
forced = _scan_force_next
|
||||
_scan_force_next = False
|
||||
try:
|
||||
background_scan(force=forced)
|
||||
background_scan()
|
||||
except Exception:
|
||||
log.exception("background scan failed unexpectedly")
|
||||
|
||||
|
||||
+1
-59
@@ -27,11 +27,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from song import compute_smart_names
|
||||
from tunings import (
|
||||
DEFAULT_PERSPECTIVE, PERSPECTIVES, ROLE_PERSPECTIVES, normalize_offsets,
|
||||
perspective_low_pitch, perspective_tuning_key, perspective_tuning_name,
|
||||
tuning_name,
|
||||
)
|
||||
from tunings import tuning_name
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
@@ -47,56 +43,6 @@ 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)
|
||||
@@ -106,8 +52,6 @@ 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.
|
||||
@@ -142,8 +86,6 @@ 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
|
||||
|
||||
+33
-211
@@ -80,20 +80,6 @@ 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.
|
||||
|
||||
@@ -730,125 +716,6 @@ 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."""
|
||||
# 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 / rel).resolve()
|
||||
dt_path.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
|
||||
if 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 _resolve_drum_parts(
|
||||
source_dir: Path,
|
||||
drum_tab_rel: object,
|
||||
drum_tab_data: dict | None,
|
||||
drum_pointer_entries: list[dict],
|
||||
) -> tuple[dict | None, list[dict] | None]:
|
||||
"""Resolve drum pointers into a primary-first list with unique ids."""
|
||||
if drum_tab_data is None and not drum_pointer_entries:
|
||||
return drum_tab_data, None
|
||||
|
||||
primary_id = "drums"
|
||||
primary_name = 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
|
||||
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,
|
||||
})
|
||||
|
||||
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})
|
||||
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(
|
||||
@@ -873,7 +740,6 @@ 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__)
|
||||
@@ -882,30 +748,7 @@ 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())
|
||||
_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"),
|
||||
)
|
||||
if not rel and not has_notation_key:
|
||||
continue
|
||||
data = None
|
||||
if rel:
|
||||
@@ -935,11 +778,6 @@ 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:
|
||||
@@ -1016,13 +854,32 @@ 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:
|
||||
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.
|
||||
drum_tab_data, drum_parts = _resolve_drum_parts(
|
||||
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
|
||||
)
|
||||
# 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-only sloppak: every GP track was percussion, so it ships a
|
||||
# drum_tab but no pitched arrangements. The highway WS rejects an empty
|
||||
@@ -1243,19 +1100,12 @@ def load_song(
|
||||
sfile = str(s.get("file", ""))
|
||||
if not sid or not sfile:
|
||||
continue
|
||||
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)
|
||||
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})
|
||||
|
||||
# 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
|
||||
@@ -1350,7 +1200,6 @@ 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,
|
||||
@@ -1378,27 +1227,6 @@ 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)
|
||||
@@ -1421,10 +1249,6 @@ 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] = []
|
||||
@@ -1463,8 +1287,6 @@ 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,
|
||||
|
||||
+7
-58
@@ -56,13 +56,6 @@ 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
|
||||
@@ -182,12 +175,6 @@ 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
|
||||
@@ -285,10 +272,6 @@ 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
|
||||
|
||||
|
||||
@@ -509,8 +492,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."""
|
||||
base = base_open_string_midis(arrangement_string_count(arr),
|
||||
arrangement_is_bass(arr))
|
||||
is_bass = "bass" in (arr.name or "").lower()
|
||||
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
|
||||
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
|
||||
arr.tuning or [], note.string, note.fret)
|
||||
|
||||
@@ -549,10 +532,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -639,23 +618,6 @@ 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.
|
||||
|
||||
@@ -673,17 +635,10 @@ 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. **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).
|
||||
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.
|
||||
|
||||
A third signal — ``len(arr.tuning)`` when it isn't the arrangement XML
|
||||
padded value of 6 — folds in for sloppak / GP-imported sources
|
||||
@@ -714,10 +669,6 @@ 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
|
||||
@@ -733,9 +684,7 @@ 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
|
||||
# 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
|
||||
name_based = 4 if "bass" in arr.name.lower() 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.
|
||||
|
||||
+6
-237
@@ -416,258 +416,27 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
})
|
||||
return out
|
||||
|
||||
# ── 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
|
||||
|
||||
# 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
|
||||
# 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 (#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.
|
||||
# 7+-string community content falls through to the numeric fallback. See #43.
|
||||
|
||||
# Standard tunings (all strings same offset)
|
||||
# Standard tunings (all six 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) in (4, 6) and all(o == offsets[0] for o in offsets):
|
||||
if len(offsets) == 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) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
|
||||
if len(offsets) == 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,13 +44,6 @@ 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
+1
-964
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -14,7 +14,6 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"tailwindcss": "^3.4.19"
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
Object.freeze({
|
||||
id: 'player-audio',
|
||||
label: 'Player and Audio Runtime',
|
||||
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']),
|
||||
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']),
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'plugin-defined',
|
||||
@@ -50,7 +50,6 @@
|
||||
'audio-monitoring': 'headphones',
|
||||
stems: 'sliders',
|
||||
'note-detection': 'activity',
|
||||
'chart-transform': 'box',
|
||||
diagnostics: 'fileSearch',
|
||||
pipeline: 'activity',
|
||||
'ui.navigation': 'list',
|
||||
|
||||
+19
-99
@@ -46,8 +46,6 @@ 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"
|
||||
@@ -55,9 +53,6 @@ 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 = {
|
||||
@@ -104,13 +99,6 @@ 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"]
|
||||
@@ -529,39 +517,27 @@ def _current_venue():
|
||||
return best
|
||||
|
||||
|
||||
def _fill_genre_songs(gkey, exclude, limit):
|
||||
"""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.
|
||||
"""
|
||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
||||
still gets a full set (playing them is how stubs start).
|
||||
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
|
||||
songs, single-user); push the match 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 FROM songs"
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
||||
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
||||
).fetchall()
|
||||
pool = [
|
||||
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||
for filename, title, artist, genre in rows
|
||||
if _genre_key(genre) == gkey and filename not in exclude
|
||||
]
|
||||
random.shuffle(pool) # re-roll must vary; free per call
|
||||
return pool[:limit]
|
||||
out = []
|
||||
for filename, title, artist, genre in rows:
|
||||
if _genre_key(genre) != gkey or filename in exclude:
|
||||
continue
|
||||
out.append({"filename": filename, "title": title or filename,
|
||||
"artist": artist or ""})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
@@ -671,7 +647,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 _pack_published(v.get("pack")),
|
||||
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
|
||||
"download": dl,
|
||||
})
|
||||
return {
|
||||
@@ -758,62 +734,6 @@ def setup(app, context):
|
||||
"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 "")
|
||||
@@ -855,7 +775,7 @@ def setup(app, context):
|
||||
picks.append(s)
|
||||
if len(picks) < size:
|
||||
exclude = {s["filename"] for s in picks}
|
||||
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
|
||||
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
||||
if not picks:
|
||||
raise HTTPException(404, "No songs of this genre in the library.")
|
||||
venue = _current_venue()
|
||||
@@ -941,7 +861,7 @@ def setup(app, context):
|
||||
if venue is None:
|
||||
raise HTTPException(404, "Unknown venue.")
|
||||
pack = venue.get("pack")
|
||||
if not _pack_published(pack):
|
||||
if not pack:
|
||||
raise HTTPException(404, "No pack published for this venue yet.")
|
||||
stars_total, _, _ = _stars()
|
||||
if stars_total < venue["star_threshold"]:
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
'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';
|
||||
@@ -1127,52 +1123,10 @@
|
||||
sfx('page');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function startGig() {
|
||||
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).
|
||||
@@ -1192,21 +1146,7 @@
|
||||
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,
|
||||
@@ -1484,7 +1424,7 @@
|
||||
}
|
||||
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-play]')) { startGig(); return; }
|
||||
if (e.target.closest('[data-pp-gig-reroll]')) {
|
||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||
return;
|
||||
|
||||
@@ -17,22 +17,14 @@
|
||||
"name": "Velvet Room",
|
||||
"description": "A proper club stage. People actually came to hear you.",
|
||||
"star_threshold": 50,
|
||||
"pack": {
|
||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"bytes": 0
|
||||
}
|
||||
"pack": null
|
||||
},
|
||||
{
|
||||
"id": "arena",
|
||||
"name": "Feedback Arena",
|
||||
"description": "Ten thousand seats. Try not to think about it.",
|
||||
"star_threshold": 150,
|
||||
"pack": {
|
||||
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
|
||||
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
|
||||
"bytes": 351284599
|
||||
}
|
||||
"pack": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -878,146 +878,6 @@ 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) {
|
||||
@@ -1299,8 +1159,8 @@ function createFolderSurface(cfg) {
|
||||
|
||||
var _listPopulated = open;
|
||||
function _populateList() {
|
||||
_fillSongList(list, folder.songs, function (s) {
|
||||
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||
_sortSongs(folder.songs).forEach(function (s) {
|
||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
||||
});
|
||||
(folder.children || []).forEach(function (child) {
|
||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||
@@ -1335,18 +1195,12 @@ function createFolderSurface(cfg) {
|
||||
hdr.addEventListener('click', function () {
|
||||
if (_query()) return;
|
||||
var nowOpen = content.style.display === 'none';
|
||||
// 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; }
|
||||
content.style.display = nowOpen ? '' : 'none';
|
||||
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);
|
||||
@@ -1391,8 +1245,8 @@ function createFolderSurface(cfg) {
|
||||
}
|
||||
var _populated = _unsortedOpen;
|
||||
function _populate() {
|
||||
_fillSongList(list, songs, function (s) {
|
||||
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||
_sortSongs(songs).forEach(function (s) {
|
||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
||||
});
|
||||
}
|
||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||
@@ -1401,12 +1255,10 @@ function createFolderSurface(cfg) {
|
||||
hdr.addEventListener('click', function () {
|
||||
if (_query()) return;
|
||||
_unsortedOpen = list.style.display === 'none';
|
||||
// Show BEFORE populating — see the folder toggle above.
|
||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||
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);
|
||||
@@ -1488,10 +1340,6 @@ 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();
|
||||
@@ -1603,7 +1451,6 @@ 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 = '';
|
||||
@@ -1707,8 +1554,6 @@ 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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1811,7 +1656,6 @@ 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.
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
// 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` 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.
|
||||
`tuning` and `capo` aren't consumed by this plugin.
|
||||
|
||||
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.34.1",
|
||||
"version": "3.31.5",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+15829
-16677
File diff suppressed because it is too large
Load Diff
@@ -1,543 +0,0 @@
|
||||
// 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.
|
||||
//
|
||||
// screen.js is a single ~16k-line IIFE, so the control cannot be imported. The
|
||||
// self-contained `_pc*` block is sliced out of the real source and evaluated
|
||||
// with its few collaborators stubbed (BG_STYLE_IDS, _bgReadSetting,
|
||||
// _bgSubscribe/_bgUnsubscribe). The slice markers are asserted before use: move
|
||||
// or rename the block and this fails loudly rather than testing nothing.
|
||||
|
||||
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 START = ' const _PC_LABELS = {';
|
||||
const END_CRLF = ' /* ======================================================================\r\n * Factory';
|
||||
const END_LF = ' /* ======================================================================\n * Factory';
|
||||
|
||||
// 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 } = {}) {
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
const start = src.indexOf(START);
|
||||
assert.notEqual(start, -1, 'could not find the _PC_LABELS marker in screen.js');
|
||||
let end = src.indexOf(END_CRLF);
|
||||
if (end === -1) end = src.indexOf(END_LF);
|
||||
assert.notEqual(end, -1, 'could not find the Factory banner marker in screen.js');
|
||||
assert.ok(end > start, 'slice markers found out of order in screen.js');
|
||||
const block = src.slice(start, end);
|
||||
|
||||
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 = [];
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
BG_STYLE_IDS,
|
||||
// Module-scope in screen.js; the _pc* block reads it to resolve the
|
||||
// effective style under the Venue override. Tests flip it via
|
||||
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
|
||||
_venueSceneOverride: false,
|
||||
_bgReadSetting: (_panelKey, key) => store[key],
|
||||
_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;
|
||||
|
||||
const api = vm.runInNewContext(
|
||||
block
|
||||
+ '\n({ _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; } })',
|
||||
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, '');
|
||||
});
|
||||
@@ -7,26 +7,15 @@
|
||||
#
|
||||
# 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")/.."
|
||||
# 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 \
|
||||
# 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 \
|
||||
-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, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
|
||||
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, 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,10 +1115,7 @@ async def startup_status_stream(request: Request):
|
||||
@app.post("/api/rescan")
|
||||
def trigger_rescan():
|
||||
"""Manually trigger a library rescan."""
|
||||
# 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):
|
||||
if not scan.kick_scan():
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Rescan started"}
|
||||
|
||||
@@ -1136,7 +1133,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(force=True):
|
||||
if not scan.kick_scan():
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Full rescan started"}
|
||||
|
||||
@@ -1618,12 +1615,6 @@ 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+5
-57
@@ -1150,7 +1150,7 @@ window.feedBack.on('song:ready', () => {
|
||||
let _arrBusyGen = 0;
|
||||
let _arrBusyTimeout = null;
|
||||
|
||||
async function changeArrangement(index, drumPart) {
|
||||
async function changeArrangement(index) {
|
||||
if (currentFilename) {
|
||||
// Tear down any pending fresh-load credits before switching: the
|
||||
// no-count-in hold timer would otherwise fire togglePlay() against the
|
||||
@@ -1276,38 +1276,11 @@ async function changeArrangement(index, drumPart) {
|
||||
_resetSectionPracticeLog();
|
||||
invalidateParentCount();
|
||||
|
||||
// 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.highway.reconnect(currentFilename, index);
|
||||
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().
|
||||
@@ -1361,25 +1334,12 @@ 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 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;
|
||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||
}
|
||||
function start(files, opts) {
|
||||
@@ -1411,15 +1371,6 @@ 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
|
||||
@@ -2346,14 +2297,11 @@ 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, changeDrumPart, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
|
||||
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
|
||||
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
||||
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
||||
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
||||
|
||||
@@ -128,7 +128,6 @@
|
||||
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({});
|
||||
|
||||
@@ -1537,4 +1536,4 @@
|
||||
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
|
||||
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
|
||||
} catch (_) {}
|
||||
})();
|
||||
})();
|
||||
@@ -1,336 +0,0 @@
|
||||
// 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();
|
||||
})();
|
||||
+25
-268
@@ -267,19 +267,6 @@ 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
|
||||
@@ -410,8 +397,7 @@ function createHighway() {
|
||||
function getAnchorAt(t) {
|
||||
// Same master-difficulty fallback as the render loops — the
|
||||
// anchor ladder pairs with the note ladder.
|
||||
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
const src = 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;
|
||||
@@ -422,8 +408,7 @@ function createHighway() {
|
||||
|
||||
function getMaxFretInWindow(t) {
|
||||
// Find the highest fret needed across all anchors visible on screen
|
||||
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
const src = 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)
|
||||
@@ -556,20 +541,17 @@ function createHighway() {
|
||||
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
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.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.beats = hwState.beats;
|
||||
b.sections = hwState.sections;
|
||||
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.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.lyrics = hwState.lyrics;
|
||||
b.lyricsSource = hwState.lyricsSource;
|
||||
b.toneChanges = hwState.toneChanges;
|
||||
@@ -590,10 +572,9 @@ 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._xfHandShapes !== null ? hwState._xfHandShapes
|
||||
: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes;
|
||||
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes;
|
||||
|
||||
// Display flags
|
||||
b.inverted = hwState._inverted;
|
||||
@@ -1005,22 +986,6 @@ 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;
|
||||
@@ -1194,17 +1159,6 @@ 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;
|
||||
@@ -1269,15 +1223,7 @@ function createHighway() {
|
||||
const _nowP = performance.now();
|
||||
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
||||
_paused = true;
|
||||
// ...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;
|
||||
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
hwState._lastPausedDrawAt = _nowP;
|
||||
}
|
||||
}
|
||||
@@ -1391,10 +1337,9 @@ 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 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 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 y = strTop + (yi / span) * (strBot - strTop);
|
||||
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
|
||||
hwState.ctx.lineWidth = 3;
|
||||
@@ -1497,7 +1442,6 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
_restageChartTransform();
|
||||
return;
|
||||
}
|
||||
const outNotes = [];
|
||||
@@ -1545,116 +1489,6 @@ 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 ───────────────────────────────────────────────────────
|
||||
@@ -1699,8 +1533,6 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
// Keep _xfProvider (persists across songs); drop staged output.
|
||||
_clearChartTransformStage();
|
||||
_resetChordRenderState();
|
||||
},
|
||||
|
||||
@@ -2298,31 +2130,6 @@ 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) {
|
||||
@@ -2405,22 +2212,7 @@ 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)) {
|
||||
@@ -2627,11 +2419,8 @@ function createHighway() {
|
||||
hwState._domVisSampledFrame = NaN;
|
||||
return _isHighwayVisible();
|
||||
},
|
||||
// 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; },
|
||||
getNotes() { return hwState.notes; },
|
||||
getChords() { return 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
|
||||
@@ -2639,14 +2428,8 @@ 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() {
|
||||
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;
|
||||
},
|
||||
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; },
|
||||
getFilteredChords() { 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 }`:
|
||||
@@ -2661,7 +2444,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._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
|
||||
getChordTemplates() { return hwState.chordTemplates; },
|
||||
getToneChanges() { return hwState.toneChanges; },
|
||||
getToneBase() { return hwState.toneBase; },
|
||||
getSections() { return hwState.sections; },
|
||||
@@ -2689,10 +2472,7 @@ 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._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; },
|
||||
getStringCount() { return hwState.stringCount; },
|
||||
addDrawHook(fn) {
|
||||
hwState._drawHooks.push(fn);
|
||||
},
|
||||
@@ -2716,17 +2496,6 @@ 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(); },
|
||||
/**
|
||||
@@ -2813,7 +2582,7 @@ function createHighway() {
|
||||
localStorage.setItem('showFingerHints', String(hwState._showFingerHints));
|
||||
},
|
||||
|
||||
reconnect(filename, arrangement, drumPart) {
|
||||
reconnect(filename, arrangement) {
|
||||
// Close old WS but keep audio + animation running
|
||||
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
|
||||
hwState.ready = false;
|
||||
@@ -2834,16 +2603,9 @@ 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();
|
||||
@@ -2938,11 +2700,6 @@ 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();
|
||||
|
||||
+10
-21
@@ -400,9 +400,8 @@ 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. An active chart transform substitutes its staged view.
|
||||
const src = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// is drawn.
|
||||
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
for (const n of src) {
|
||||
if (n.sus <= 0.01) continue;
|
||||
const end = n.t + n.sus;
|
||||
@@ -502,9 +501,7 @@ 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.
|
||||
// An active chart transform (_xfNotes) substitutes its staged view.
|
||||
const src = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// Binary search for visible range
|
||||
const tMin = hwState.currentTime - 0.25;
|
||||
const tMax = hwState.currentTime + VISIBLE_SECONDS;
|
||||
@@ -652,8 +649,7 @@ 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._xfChords !== null ? hwState._xfChords
|
||||
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
_ensureChordRenderCache(hwState, src);
|
||||
|
||||
const tMin = hwState.currentTime - 0.25;
|
||||
@@ -678,7 +674,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, _effChordTemplates(hwState));
|
||||
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const hasNonZero = nonZeroNotes.length >= 1;
|
||||
|
||||
const frameLeftFret = baseFret;
|
||||
@@ -1128,22 +1124,15 @@ 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 effTemplates = _effChordTemplates(hwState);
|
||||
const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates;
|
||||
const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates;
|
||||
if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
|
||||
hwState._chordRenderCacheSrc = src;
|
||||
hwState._chordRenderCacheInverted = hwState._inverted;
|
||||
hwState._chordRenderCacheTemplates = effTemplates;
|
||||
hwState._chordRenderCacheTemplates = hwState.chordTemplates;
|
||||
// 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
|
||||
@@ -1199,7 +1188,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, effTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
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);
|
||||
@@ -1259,7 +1248,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
|
||||
ch.t > bestChordTime) {
|
||||
bestChordTime = ch.t;
|
||||
activeChord = ch;
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const nonZero = ch.notes.filter(cn => !isOpen(cn));
|
||||
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
|
||||
}
|
||||
@@ -1271,7 +1260,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, _effChordTemplates(hwState));
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const nonZero = ch.notes.filter(cn => !isOpen(cn));
|
||||
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
|
||||
break;
|
||||
|
||||
+4
-71
@@ -410,51 +410,6 @@ 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;
|
||||
@@ -813,8 +768,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -898,7 +851,6 @@ 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();
|
||||
@@ -917,11 +869,6 @@ 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>`;
|
||||
@@ -947,17 +894,10 @@ 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}${inferred ? ` (${inferred}~)` : ''}</span>`;
|
||||
`<span class="tuning-count">${t.count}</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.onchange = () => {
|
||||
const i = _libFilters.tunings.indexOf(val);
|
||||
@@ -1304,10 +1244,6 @@ 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';
|
||||
@@ -1363,7 +1299,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(''); })()}
|
||||
${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>` : ''}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
@@ -1534,9 +1470,6 @@ 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 &&
|
||||
@@ -1563,8 +1496,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 (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 (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 (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)
|
||||
|
||||
+3
-12
@@ -638,18 +638,9 @@ 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 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();
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
|
||||
+78
-260
@@ -18,7 +18,7 @@
|
||||
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
|
||||
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
|
||||
import { hwcInitSettingsUI } from './highway-colors.js';
|
||||
import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
|
||||
import { _getArrangementNamingMode } from './library.js';
|
||||
import {
|
||||
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
|
||||
} from './player-controls.js';
|
||||
@@ -111,10 +111,6 @@ export async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
// Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
|
||||
// tuning facet, filter, sort and badges all answer for the profile the
|
||||
// player actually plays.
|
||||
_setLibraryProfile(data.active_instrument_profile);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
@@ -213,66 +209,9 @@ export function setupWindowOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly'];
|
||||
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
|
||||
|
||||
export let _appUpdatesWired = false;
|
||||
// Poll handle for the active-download watcher (module-scoped so re-running
|
||||
// setupAppUpdates on a panel re-render never stacks a second poll).
|
||||
let _appUpdatePollTimer = null;
|
||||
// Last channel main actually acknowledged (initial sync or a successful
|
||||
// user switch). Used to revert the dropdown/localStorage if a switch fails,
|
||||
// so the UI/persisted state can never end up ahead of the real updater state.
|
||||
let _appUpdateAckedChannel = null;
|
||||
// Last [update-diag] renderFrom line logged, so the ~1.5s download poll (and
|
||||
// repeated no-op re-renders) don't flood the diagnostics ring buffer with
|
||||
// byte-identical lines and evict genuinely useful trace. Every real state or
|
||||
// percent change still differs and logs; the structured contribute() snapshot
|
||||
// (with its own ts) is unconditional, so liveness is never lost.
|
||||
let _appUpdateLastRenderLog = null;
|
||||
|
||||
// Pure status → view model for the App-updates panel. DOM-free and exported so
|
||||
// the button/channel/text state machine can be unit-tested without a browser;
|
||||
// renderFrom() applies the returned shape to the DOM. `canApply` is whether the
|
||||
// bridge exposes apply() (older bridges fall back to text-only), `fmtTimestamp`
|
||||
// formats the "last checked" time, `channelValue` is the dropdown's fallback
|
||||
// when the status omits a channel.
|
||||
export function _appUpdateStatusView(s, { channelValue, canApply = true, fmtTimestamp = (t) => String(t) } = {}) {
|
||||
if (!s) return { kind: 'unavailable' };
|
||||
if (s.status === 'unsupported' || s.platform === 'linux') return { kind: 'unsupported' };
|
||||
const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelValue}`;
|
||||
let action;
|
||||
let btnLabel = 'Check for updates';
|
||||
let btnMode = 'check';
|
||||
let btnDisabled = false;
|
||||
// Lock the channel selector only while a check/download is in flight —
|
||||
// switching mid-operation abandons it. Enabled for every other status.
|
||||
const channelDisabled = s.status === 'checking' || s.status === 'downloading';
|
||||
switch (s.status) {
|
||||
case 'checking':
|
||||
action = 'checking for updates…';
|
||||
btnDisabled = true;
|
||||
break;
|
||||
case 'downloading': {
|
||||
const pct = typeof s.percent === 'number' ? s.percent : null;
|
||||
action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`;
|
||||
btnDisabled = true;
|
||||
break;
|
||||
}
|
||||
case 'downloaded':
|
||||
action = 'update ready';
|
||||
if (canApply) { btnLabel = 'Restart now'; btnMode = 'restart'; }
|
||||
else { action = 'update ready — restart to apply'; }
|
||||
break;
|
||||
case 'error':
|
||||
action = s.message ? `update error: ${s.message}` : 'update check failed';
|
||||
break;
|
||||
case 'idle':
|
||||
default:
|
||||
action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`;
|
||||
break;
|
||||
}
|
||||
return { kind: 'status', line: `${base} · ${action}`, btnLabel, btnMode, btnDisabled, channelDisabled };
|
||||
}
|
||||
|
||||
export function setupAppUpdates() {
|
||||
const block = document.getElementById('app-updates-block');
|
||||
@@ -305,29 +244,13 @@ export function setupAppUpdates() {
|
||||
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
|
||||
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
|
||||
channelSelect.value = stored;
|
||||
_appUpdateAckedChannel = stored;
|
||||
|
||||
// Diagnostic: every entry into this function, with whether the one-time
|
||||
// sync gate has already fired. _appUpdatesWired is a MODULE-level `let`,
|
||||
// so it only resets to false on a genuine fresh evaluation of this
|
||||
// script (a real page reload/navigation) — not on loadSettings() simply
|
||||
// being called again within the same page. A second "wired=false" in one
|
||||
// exported log is direct proof of a reload; a series of "wired=true"
|
||||
// entries proves it's just repeated Settings-panel visits (harmless).
|
||||
console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored }));
|
||||
const isLinux = window.feedBackDesktop?.platform === 'linux';
|
||||
|
||||
function showLinuxFallback(message) {
|
||||
// Deliberately leaves channelSelect ENABLED: on Linux "unsupported"
|
||||
// usually just means "the channel isn't Nightly yet", and the dropdown
|
||||
// is the only way to switch to Nightly. Disabling it would trap the
|
||||
// user on whatever channel they booted with. Only the check button and
|
||||
// the note reflect the unsupported state.
|
||||
if (linuxNote) linuxNote.classList.remove('hidden');
|
||||
channelSelect.disabled = true;
|
||||
checkBtn.disabled = true;
|
||||
// Reset the button out of any leftover "Restart now" state (e.g. an
|
||||
// update was staged on nightly, then the user switched channels).
|
||||
checkBtn.textContent = 'Check for updates';
|
||||
checkBtn.dataset.mode = 'check';
|
||||
statusEl.textContent = message || 'Auto-update is not available on this platform.';
|
||||
}
|
||||
|
||||
@@ -339,140 +262,61 @@ export function setupAppUpdates() {
|
||||
} catch (_) { return 'never'; }
|
||||
}
|
||||
|
||||
// Render one status object. Always keeps the current version + channel
|
||||
// visible and appends what's happening, so the download progress never
|
||||
// obscures which build you're on.
|
||||
function renderFrom(s, extra) {
|
||||
// Diagnostic trace: log the raw status object before any branching —
|
||||
// auto-captured by diagnostics.js's console wrap into the exportable
|
||||
// ring buffer, so "Export Diagnostics" in this same Settings → System
|
||||
// panel captures exactly what the app saw and decided, not just what
|
||||
// the UI showed. Deduped so a steady poll doesn't flood the ring buffer
|
||||
// (see _appUpdateLastRenderLog); a real state/percent change differs and
|
||||
// still logs; the structured contribute() snapshot below is unconditional.
|
||||
const logKey = `${JSON.stringify(s)}|${extra || ''}`;
|
||||
if (logKey !== _appUpdateLastRenderLog) {
|
||||
_appUpdateLastRenderLog = logKey;
|
||||
console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : '');
|
||||
}
|
||||
const view = _appUpdateStatusView(s, {
|
||||
channelValue: channelSelect.value,
|
||||
canApply: typeof updateApi.apply === 'function',
|
||||
fmtTimestamp,
|
||||
});
|
||||
if (view.kind === 'unavailable') { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
|
||||
if (view.kind === 'unsupported') {
|
||||
showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.');
|
||||
return;
|
||||
}
|
||||
// Healthy for the current channel — clear any "unsupported" UI left
|
||||
// over from a prior channel selection.
|
||||
if (linuxNote) linuxNote.classList.add('hidden');
|
||||
// The button is a little state machine (dataset.mode drives the click
|
||||
// handler's restart-vs-check branch); the channel selector locks only
|
||||
// while a check/download is active. See _appUpdateStatusView.
|
||||
channelSelect.disabled = view.channelDisabled;
|
||||
checkBtn.textContent = view.btnLabel;
|
||||
checkBtn.dataset.mode = view.btnMode;
|
||||
checkBtn.disabled = view.btnDisabled;
|
||||
const line = view.line;
|
||||
statusEl.textContent = extra ? `${extra} · ${line}` : line;
|
||||
|
||||
// Live structured snapshot (overwrites, not a log) via the existing
|
||||
// diagnostics contribute() API — 'audio_engine' is feedBack-desktop's
|
||||
// own registered plugin id, so the server's diagnostics export won't
|
||||
// filter it out. Always current, no scrolling through console history
|
||||
// needed to answer "what does the app think is going on right now."
|
||||
try {
|
||||
window.feedBack?.diagnostics?.contribute('audio_engine', {
|
||||
update: {
|
||||
channel: s.channel || channelSelect.value,
|
||||
status: s.status,
|
||||
currentVersion: s.currentVersion ?? null,
|
||||
lastChecked: s.lastChecked ?? null,
|
||||
percent: typeof s.percent === 'number' ? s.percent : null,
|
||||
message: s.message ?? null,
|
||||
rendered: line,
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
} catch (_) { /* diagnostics.js not loaded — never let this break rendering */ }
|
||||
|
||||
// A download runs in the background (the check returns immediately), so
|
||||
// poll for the terminal state rather than relying solely on a one-shot
|
||||
// "downloaded" event that could be missed or arrive out of order.
|
||||
if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy();
|
||||
}
|
||||
|
||||
function renderStatus(extra) {
|
||||
try {
|
||||
// Wrap in Promise.resolve so a future getStatus() that returns
|
||||
// synchronously won't blow up on .then().
|
||||
void Promise.resolve(updateApi.getStatus())
|
||||
.then((s) => renderFrom(s, extra))
|
||||
.catch((e) => {
|
||||
console.warn('[updater] getStatus failed:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
});
|
||||
void Promise.resolve(updateApi.getStatus()).then((s) => {
|
||||
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
|
||||
if (s.status === 'unsupported' || s.platform === 'linux') {
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.';
|
||||
statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg;
|
||||
return;
|
||||
}
|
||||
const parts = [
|
||||
`Version ${s.currentVersion || '?'}`,
|
||||
`channel ${s.channel || channelSelect.value}`,
|
||||
`last checked ${fmtTimestamp(s.lastChecked)}`,
|
||||
];
|
||||
statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · ');
|
||||
}).catch((e) => {
|
||||
console.warn('[updater] getStatus failed:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] getStatus threw:', e);
|
||||
statusEl.textContent = extra || 'Failed to read updater status.';
|
||||
}
|
||||
}
|
||||
|
||||
// While a download (or check) is active, re-read the authoritative status
|
||||
// every ~1.5s and stop once it settles (downloaded / idle / error). This is
|
||||
// what guarantees the panel leaves "downloading… 100%" and lands on "update
|
||||
// ready" (or surfaces a swap error) even if the completion event is lost.
|
||||
function pollWhileBusy() {
|
||||
if (_appUpdatePollTimer) return;
|
||||
_appUpdatePollTimer = setInterval(() => {
|
||||
void Promise.resolve(updateApi.getStatus()).then((s) => {
|
||||
renderFrom(s);
|
||||
const st = s && s.status;
|
||||
if (st !== 'downloading' && st !== 'checking') {
|
||||
clearInterval(_appUpdatePollTimer);
|
||||
_appUpdatePollTimer = null;
|
||||
}
|
||||
}).catch(() => {
|
||||
clearInterval(_appUpdatePollTimer);
|
||||
_appUpdatePollTimer = null;
|
||||
});
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Inform main of the persisted channel — but ONLY the first time this page
|
||||
// wires up, not on every loadSettings() re-render. This used to run
|
||||
// unconditionally on every call and was caught (via Export Diagnostics)
|
||||
// stomping an in-flight check/download: a redundant setChannel() call
|
||||
// mid-download bumps main's checkGeneration and resets progress state,
|
||||
// so the download silently loses its ability to report completion even
|
||||
// though the file swap itself still happens in the background. Once
|
||||
// wired, the channel select's own 'change' handler is the only thing
|
||||
// that needs to tell main about a channel switch.
|
||||
if (!_appUpdatesWired) {
|
||||
if (isLinux) {
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
// Keep main informed of the persisted channel even on Linux so
|
||||
// cross-platform reasoning about the channel stays consistent.
|
||||
// setChannel() may return a Promise — chain .catch() so a rejected
|
||||
// promise doesn't surface as an unhandled rejection.
|
||||
try {
|
||||
// Render from THIS call's own result (same reasoning as the check
|
||||
// button and the 'change' handler below), not just catch its
|
||||
// errors. The unconditional renderStatus() at the bottom of this
|
||||
// function fires a SEPARATE getStatus() round-trip immediately
|
||||
// after — if that resolves before main has processed this
|
||||
// setChannel() (e.g. main is still on its 'stable' boot default),
|
||||
// the UI would render 'unsupported' and — since this call's own
|
||||
// eventual success was never rendered — get stuck there
|
||||
// permanently, even once main correctly switches channel a moment
|
||||
// later. Rendering here too means whichever of the two calls
|
||||
// resolves LAST wins and shows the true state, regardless of
|
||||
// which order they land in.
|
||||
void Promise.resolve(updateApi.setChannel(stored)).then((result) => {
|
||||
_appUpdateAckedChannel = stored;
|
||||
renderFrom(result);
|
||||
}).catch((e) => {
|
||||
console.warn('[updater] setChannel(initial) failed:', e);
|
||||
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
|
||||
console.warn('[updater] setChannel(linux) failed:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(initial) threw:', e);
|
||||
console.warn('[updater] setChannel(linux) threw:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Inform main of the persisted channel on each load. setChannel() on
|
||||
// main is idempotent when the channel already matches.
|
||||
try {
|
||||
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
|
||||
console.warn('[updater] setChannel(initial) failed:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel(initial) threw:', e);
|
||||
}
|
||||
|
||||
if (!_appUpdatesWired) {
|
||||
@@ -482,82 +326,56 @@ export function setupAppUpdates() {
|
||||
channelSelect.addEventListener('change', async () => {
|
||||
const val = channelSelect.value;
|
||||
if (!APP_UPDATE_CHANNELS.includes(val)) return;
|
||||
console.log('[update-diag] user switched channel to', val);
|
||||
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
|
||||
try {
|
||||
// Render from setChannel()'s own return value (same reasoning
|
||||
// as the check button: it's computed synchronously at the
|
||||
// moment of the switch, so it can't be stale, unlike a
|
||||
// follow-up getStatus() call).
|
||||
const result = await Promise.resolve(updateApi.setChannel(val));
|
||||
// Only persist once main has actually acknowledged the switch —
|
||||
// a failed setChannel() must never leave localStorage (or the
|
||||
// dropdown) ahead of what main is really using.
|
||||
_appUpdateAckedChannel = val;
|
||||
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
|
||||
renderFrom(result, `Channel set to ${val}.`);
|
||||
// Await setChannel so the status line reflects what actually
|
||||
// happened — rendering "Channel set" unconditionally would
|
||||
// mislead users when the IPC rejects.
|
||||
await Promise.resolve(updateApi.setChannel(val));
|
||||
renderStatus(`Channel set to ${val}.`);
|
||||
} catch (e) {
|
||||
console.warn('[updater] setChannel failed:', e);
|
||||
channelSelect.value = _appUpdateAckedChannel ?? 'stable';
|
||||
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
|
||||
}
|
||||
});
|
||||
|
||||
checkBtn.addEventListener('click', async () => {
|
||||
// In restart mode (set by renderFrom once an update is staged) the
|
||||
// button applies the update instead of checking again.
|
||||
if (checkBtn.dataset.mode === 'restart') {
|
||||
console.log('[update-diag] user clicked Restart now');
|
||||
checkBtn.disabled = true;
|
||||
checkBtn.textContent = 'Restarting…';
|
||||
try {
|
||||
const r = await updateApi.apply();
|
||||
if (r?.status === 'error') {
|
||||
console.warn('[updater] apply returned error:', r.message || 'unknown');
|
||||
renderFrom(r, 'Restart failed.');
|
||||
}
|
||||
// On success the app quits + relaunches — nothing to render.
|
||||
} catch (e) {
|
||||
console.warn('[updater] apply failed:', e);
|
||||
statusEl.textContent = `Restart failed: ${e?.message || e}`;
|
||||
checkBtn.textContent = 'Restart now';
|
||||
checkBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log('[update-diag] user clicked Check for updates');
|
||||
checkBtn.disabled = true;
|
||||
statusEl.textContent = 'Checking for updates…';
|
||||
let result;
|
||||
let reEnableBtn = true;
|
||||
try {
|
||||
// The Linux check returns immediately (any download runs in the
|
||||
// background).
|
||||
result = await updateApi.checkNow();
|
||||
const result = await updateApi.checkNow();
|
||||
const status = result?.status || 'unknown';
|
||||
let msg;
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
msg = "You're on the newest version in this channel.";
|
||||
break;
|
||||
case 'downloading':
|
||||
msg = 'Update available — downloading…';
|
||||
break;
|
||||
case 'downloaded':
|
||||
msg = 'Update downloaded — restart to apply.';
|
||||
break;
|
||||
case 'unsupported':
|
||||
reEnableBtn = false;
|
||||
showLinuxFallback('Auto-update is not available on Linux.');
|
||||
return;
|
||||
case 'error':
|
||||
msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`;
|
||||
break;
|
||||
default:
|
||||
msg = `Update check returned: ${status}`;
|
||||
}
|
||||
renderStatus(msg);
|
||||
} catch (e) {
|
||||
console.warn('[updater] checkNow failed:', e);
|
||||
statusEl.textContent = `Update check failed: ${e?.message || e}`;
|
||||
checkBtn.disabled = false;
|
||||
return;
|
||||
} finally {
|
||||
if (reEnableBtn) checkBtn.disabled = false;
|
||||
}
|
||||
// Render straight from checkNow()'s own return value rather than a
|
||||
// follow-up getStatus() call. checkNow() computes that value
|
||||
// synchronously at the moment it decides the outcome, so it can't
|
||||
// be stale; a separate getStatus() round-trip right after it can
|
||||
// race with anything that resets state in between (a concurrent
|
||||
// channel switch, another in-flight check settling) and show a
|
||||
// blanked "up to date · last checked never" even though this check
|
||||
// just succeeded.
|
||||
renderFrom(result);
|
||||
});
|
||||
|
||||
// Main-process events (checkNow/download decisions in update-manager.ts)
|
||||
// are invisible to this page's console — forward them into it so a
|
||||
// single "Export Diagnostics" click captures both sides of the story.
|
||||
if (typeof updateApi.onDiag === 'function') {
|
||||
updateApi.onDiag((payload) => {
|
||||
console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : '');
|
||||
});
|
||||
}
|
||||
|
||||
_appUpdatesWired = true;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,100 +0,0 @@
|
||||
// Generic gamepad menu navigation: Tab-order emulation.
|
||||
//
|
||||
// Every v3 screen except v3-songs (which has its own 2D grid nav) is built from
|
||||
// real, natively-focusable <button>/<a> elements, so real Tab/Shift+Tab and real
|
||||
// Enter/Space already work perfectly. The gap is that nothing ever calls
|
||||
// .focus() on anything, and gamepad.js only ever synthesizes Arrow keydowns —
|
||||
// it never sends Tab (browsers don't focus-traverse on a synthetic Tab anyway).
|
||||
// This fills that gap by moving focus through the same set of elements Tab
|
||||
// already visits, one step per Arrow press, treating Down/Right as "next" and
|
||||
// Up/Left as "previous".
|
||||
//
|
||||
// Gated on !e.isTrusted so this NEVER touches real keyboard/mouse users — it
|
||||
// only ever reacts to gamepad.js's synthetic events. Also bails whenever a more
|
||||
// specific handler already claimed the key (songs.js's grid nav, shortcuts.js's
|
||||
// legacy library arrow-nav, or the shortcuts registry's player-scope seek
|
||||
// shortcuts all call preventDefault() before this listener runs, since script
|
||||
// tag order puts them earlier in the document than this file).
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
|
||||
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
var ARROWS = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
|
||||
var TEXT_INPUT_TYPES = ['text', 'search', 'email', 'url', 'tel', 'password', 'number'];
|
||||
|
||||
function visible(el) {
|
||||
return el.offsetParent !== null;
|
||||
}
|
||||
|
||||
function focusScopeRoot() {
|
||||
var modal = document.querySelector('[role="dialog"][aria-modal="true"], .feedBack-modal');
|
||||
if (modal && visible(modal)) return [modal];
|
||||
var nav = document.getElementById('v3-nav');
|
||||
var screen = document.querySelector('.screen.active');
|
||||
return [nav, screen].filter(Boolean);
|
||||
}
|
||||
|
||||
function focusables() {
|
||||
var roots = focusScopeRoot();
|
||||
var els = [];
|
||||
roots.forEach(function (root) {
|
||||
Array.prototype.push.apply(els, root.querySelectorAll(FOCUSABLE));
|
||||
});
|
||||
return els.filter(visible);
|
||||
}
|
||||
|
||||
function isTextInput(el) {
|
||||
if (!el) return false;
|
||||
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
|
||||
return el.tagName === 'INPUT' && TEXT_INPUT_TYPES.includes((el.type || 'text').toLowerCase());
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.isTrusted || e.defaultPrevented) return;
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||
// Chromium doesn't run the native "Enter/Space activates the focused
|
||||
// link/button" default action for untrusted synthetic keydowns, even
|
||||
// when dispatched straight at the focused element (confirmed by
|
||||
// testing) — so without this, a focused sidebar link or dashboard
|
||||
// button just sits there forever. click() works for untrusted events.
|
||||
var active = document.activeElement;
|
||||
if (active && active !== document.body && !isTextInput(active)) active.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
// Only 'player' and 'settings' have a registered Escape shortcut
|
||||
// (shortcuts.js); every other screen (v3-songs, v3-plugins,
|
||||
// v3-playlists, ...) leaves B with nothing to do — confirmed on-device,
|
||||
// players get stuck unable to leave the library or any other screen.
|
||||
// The app never pushes history entries on navigation (shell.js
|
||||
// deliberately doesn't reflect screen changes into location.hash), so
|
||||
// history.back() isn't a real "undo the last screen" — a fixed target
|
||||
// is. Prefer an existing in-screen back button if one is visible
|
||||
// (reuses each screen's own drill-down logic for free: v3-songs'
|
||||
// artist/album pages, v3-playlists' list<->detail view), else fall
|
||||
// back to the main menu, matching the direct showScreen() call the
|
||||
// settings Escape shortcut already uses.
|
||||
// querySelector alone would only ever look at the first match in
|
||||
// DOM order across all three selectors — screens stay in the DOM
|
||||
// (hidden, not removed) when you navigate away, so a hidden back
|
||||
// button from a screen you're not on can sort before the visible
|
||||
// one that actually applies. Check every match for visibility.
|
||||
var backBtns = document.querySelectorAll('[data-ap-back], [data-albums-back], #v3-pl-back');
|
||||
var backBtn = Array.prototype.find.call(backBtns, visible);
|
||||
if (backBtn) backBtn.click();
|
||||
else if (window.showScreen) window.showScreen('v3-home');
|
||||
return;
|
||||
}
|
||||
|
||||
var dir = ARROWS[e.key];
|
||||
if (!dir) return;
|
||||
var els = focusables();
|
||||
if (!els.length) return;
|
||||
var idx = els.indexOf(document.activeElement);
|
||||
var next = idx === -1 ? 0 : Math.max(0, Math.min(els.length - 1, idx + dir));
|
||||
els[next].focus();
|
||||
});
|
||||
})();
|
||||
@@ -1,195 +0,0 @@
|
||||
// Gamepad/controller support.
|
||||
//
|
||||
// Rather than a parallel gamepad->action mapping table, this polls
|
||||
// navigator.getGamepads() and dispatches synthetic keydown events onto
|
||||
// document with the same key/code pairs a physical keyboard would send.
|
||||
// static/js/shortcuts.js's existing dispatcher (scope checks, text-field/
|
||||
// modal guards, library grid nav, player shortcuts) handles the rest.
|
||||
//
|
||||
// Steam Deck: Steam Input re-emits the Deck's controls as a standard
|
||||
// XInput-style virtual pad (both in Gaming Mode and in Desktop Mode when
|
||||
// launched via a non-Steam shortcut with a controller template), so this
|
||||
// reports mapping: 'standard' and the button layout below lines up with
|
||||
// the Deck's physical ABXY. If a pad reports a non-standard mapping
|
||||
// (e.g. raw HID with no Steam Input in between), this no-ops rather than
|
||||
// guessing button order.
|
||||
//
|
||||
// Plain non-module script; degrades to a no-op without the Gamepad API.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) return;
|
||||
|
||||
var BUTTON_KEYS = {
|
||||
// Bottom face button (Xbox A / PS Cross "X") — play/pause on the player
|
||||
// screen; also activates the currently-selected library card, since
|
||||
// Space is already treated as an activation key there alongside Enter.
|
||||
0: { key: ' ', code: 'Space' },
|
||||
1: { key: 'Escape', code: 'Escape' }, // Xbox B / PS Circle
|
||||
// 2 (Xbox X / PS Square) intentionally unmapped — undecided.
|
||||
};
|
||||
var RAIL_REVEAL_BUTTON = 3; // Y — reveals the player screen's left tool rail
|
||||
|
||||
// The player rail (#v3-player-rail) has no keyboard shortcut to reuse — it's
|
||||
// shown via CSS on #v3-railzone:hover or :focus-within (see v3.css). So
|
||||
// instead of a synthetic keydown, this directly focuses the rail's first
|
||||
// icon, which the existing :focus-within rule already reveals it for —
|
||||
// the same mechanism a Tab-key user gets for free.
|
||||
function revealPlayerRail() {
|
||||
var active = document.querySelector('.screen.active');
|
||||
if (!active || active.id !== 'player') return;
|
||||
var icon = document.querySelector('#v3-player-rail .v3-rail-icon');
|
||||
if (icon) icon.focus();
|
||||
}
|
||||
var DPAD_BUTTONS = {
|
||||
12: { key: 'ArrowUp', code: 'ArrowUp' },
|
||||
13: { key: 'ArrowDown', code: 'ArrowDown' },
|
||||
14: { key: 'ArrowLeft', code: 'ArrowLeft' },
|
||||
15: { key: 'ArrowRight', code: 'ArrowRight' },
|
||||
};
|
||||
var STICK_DEADZONE = 0.5;
|
||||
var REPEAT_DELAY_MS = 400;
|
||||
var REPEAT_INTERVAL_MS = 120;
|
||||
|
||||
var polling = false;
|
||||
var buttonWasDown = {}; // index -> bool, for edge-detection (no repeat)
|
||||
var dirWasDown = {}; // 'up'/'down'/'left'/'right' -> bool
|
||||
var dirRepeatAt = {}; // 'up'/'down'/'left'/'right' -> timestamp of next repeat
|
||||
var connectedIndices = {}; // gamepad.index -> true, tracks which slots we've announced
|
||||
|
||||
function fireKey(spec) {
|
||||
// Dispatch on the focused element (falling back to document when nothing
|
||||
// is focused), not document itself. document.activeElement is always an
|
||||
// ancestor-inclusive descendant of document, so this still bubbles up
|
||||
// through every existing document-level listener exactly as before — but
|
||||
// now a focused <button>/<a> also gets its native Enter/Space activation
|
||||
// (which never fires for a document-targeted event, since that native
|
||||
// behavior is wired to the genuinely-focused element receiving the key),
|
||||
// and any element-scoped keydown handler sees it too.
|
||||
(document.activeElement || document).dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: spec.key, code: spec.code, bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function pollButtons(gp) {
|
||||
for (var i = 0; i < gp.buttons.length; i++) {
|
||||
var down = gp.buttons[i].pressed;
|
||||
if (down && !buttonWasDown[i]) {
|
||||
if (i === RAIL_REVEAL_BUTTON) revealPlayerRail();
|
||||
else if (BUTTON_KEYS[i]) fireKey(BUTTON_KEYS[i]);
|
||||
}
|
||||
buttonWasDown[i] = down;
|
||||
}
|
||||
}
|
||||
|
||||
function stickDirections(gp) {
|
||||
var x = gp.axes[0] || 0;
|
||||
var y = gp.axes[1] || 0;
|
||||
return {
|
||||
left: x < -STICK_DEADZONE,
|
||||
right: x > STICK_DEADZONE,
|
||||
up: y < -STICK_DEADZONE,
|
||||
down: y > STICK_DEADZONE,
|
||||
};
|
||||
}
|
||||
|
||||
function pollDirection(name, spec, down, now) {
|
||||
var wasDown = !!dirWasDown[name];
|
||||
if (down && !wasDown) {
|
||||
fireKey(spec);
|
||||
dirRepeatAt[name] = now + REPEAT_DELAY_MS;
|
||||
} else if (down && wasDown && now >= (dirRepeatAt[name] || Infinity)) {
|
||||
fireKey(spec);
|
||||
dirRepeatAt[name] = now + REPEAT_INTERVAL_MS;
|
||||
}
|
||||
dirWasDown[name] = down;
|
||||
}
|
||||
|
||||
function pollDpad(gp, now) {
|
||||
var stick = stickDirections(gp);
|
||||
Object.keys(DPAD_BUTTONS).forEach(function (idx) {
|
||||
var spec = DPAD_BUTTONS[idx];
|
||||
var name = spec.key.replace('Arrow', '').toLowerCase();
|
||||
var down = (gp.buttons[idx] && gp.buttons[idx].pressed) || stick[name];
|
||||
pollDirection(name, spec, down, now);
|
||||
});
|
||||
}
|
||||
|
||||
// A disconnected gamepad's slot stays in the array (gp.connected flips to
|
||||
// false) rather than being removed — a plain truthiness check on the array
|
||||
// entry treats a stale, frozen-state disconnected pad as "still there"
|
||||
// forever, which both swallows the disconnect notice and (if the real
|
||||
// reconnected pad lands at a different index) reads dead input forever.
|
||||
function firstLiveStandardPad() {
|
||||
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
for (var i = 0; i < pads.length; i++) {
|
||||
var p = pads[i];
|
||||
if (p && p.connected && p.mapping === 'standard') return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Same standard-mapping filter as firstLiveStandardPad — otherwise a
|
||||
// still-connected non-standard raw mirror (or the real pad simply
|
||||
// reporting a different mapping) can mask the actual pad's disconnect:
|
||||
// the toast never fires and polling never stops, even though the pad
|
||||
// this module can act on is gone.
|
||||
function anyLiveStandardPad() {
|
||||
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
for (var i = 0; i < pads.length; i++) {
|
||||
var p = pads[i];
|
||||
if (p && p.connected && p.mapping === 'standard') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
var gp = firstLiveStandardPad();
|
||||
if (gp) {
|
||||
pollButtons(gp);
|
||||
pollDpad(gp, performance.now());
|
||||
}
|
||||
if (polling) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function notify(title, icon) {
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({ title: title, icon: icon, accent: '#0ea5e9', durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('gamepadconnected', function (e) {
|
||||
var idx = e.gamepad && e.gamepad.index;
|
||||
// Non-standard slots (raw HID mirrors, or anything this module can't
|
||||
// safely act on) are never tracked/toasted/polled for — only ever
|
||||
// treat a standard-mapped pad as "a controller connected". Keeping a
|
||||
// non-standard slot out of connectedIndices also keeps it out of
|
||||
// anyLiveStandardPad's count, so it can't mask a real disconnect.
|
||||
if (!e.gamepad || e.gamepad.mapping !== 'standard') return;
|
||||
if (connectedIndices[idx]) return; // already-announced slot re-firing (focus regain, etc.)
|
||||
// On the Deck, Steam Input mirrors a real pad with 1-2 virtual XInput
|
||||
// slots of its own (same physical button presses, extra indices) — only
|
||||
// toast for the first slot seen so plugging in one controller doesn't
|
||||
// spam three "connected" notices.
|
||||
var isFirstSlot = Object.keys(connectedIndices).length === 0;
|
||||
connectedIndices[idx] = true;
|
||||
|
||||
if (isFirstSlot) notify('Controller connected', '🎮');
|
||||
buttonWasDown = {};
|
||||
dirWasDown = {};
|
||||
dirRepeatAt = {};
|
||||
if (!polling) {
|
||||
polling = true;
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('gamepaddisconnected', function (e) {
|
||||
var idx = e.gamepad && e.gamepad.index;
|
||||
delete connectedIndices[idx];
|
||||
if (!anyLiveStandardPad()) {
|
||||
polling = false;
|
||||
notify('Controller disconnected', '🔌');
|
||||
}
|
||||
});
|
||||
})();
|
||||
+3
-11
@@ -133,7 +133,6 @@
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script type="module" src="/static/capabilities/visualization.js"></script>
|
||||
<script type="module" src="/static/capabilities/chart-transform.js"></script>
|
||||
<script type="module" src="/static/capabilities/note-detection.js"></script>
|
||||
<script type="module" src="/static/capabilities/midi-input.js"></script>
|
||||
<script type="module" src="/static/capabilities/interface-scale.js"></script>
|
||||
@@ -327,7 +326,7 @@
|
||||
<section>
|
||||
<details>
|
||||
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
|
||||
<span id="filter-tunings-label">Tuning</span>
|
||||
<span>Tuning</span>
|
||||
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
|
||||
</summary>
|
||||
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
|
||||
@@ -742,7 +741,6 @@
|
||||
<option value="rc">Release candidate</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha</option>
|
||||
<option value="nightly">Nightly</option>
|
||||
</select>
|
||||
<button id="app-update-check-now"
|
||||
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
|
||||
@@ -751,8 +749,8 @@
|
||||
</div>
|
||||
<p id="app-update-status" class="text-xs text-gray-500 fb-srow-wide">Loading updater status…</p>
|
||||
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 fb-srow-wide">
|
||||
Auto-update on Linux only works for the AppImage build on the Nightly channel —
|
||||
<a href="https://github.com/got-feedBack/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download other versions from GitHub Releases</a>.
|
||||
Auto-update is not available on Linux —
|
||||
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
|
||||
@@ -1194,10 +1192,6 @@
|
||||
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="v3-pop-pin" title="Select an arrangement to make it the default">☆</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="v3-pop-row hidden" id="v3-drum-part-row">
|
||||
<span class="v3-pop-label">Drum part</span>
|
||||
<select id="drum-part-select" onchange="changeDrumPart(this.value)" class="v3-pop-select max-w-[130px]" title="Which drum chart to play — a song can carry several"></select>
|
||||
</div>
|
||||
<div class="v3-pop-row">
|
||||
<span class="v3-pop-label" id="mastery-slider-label">Difficulty</span>
|
||||
<span class="flex items-center gap-2">
|
||||
@@ -1297,7 +1291,6 @@
|
||||
<script defer src="/static/v3/theme-core.js"></script>
|
||||
<script defer src="/static/v3/progression-core.js"></script>
|
||||
<script defer src="/static/v3/notifications.js"></script>
|
||||
<script defer src="/static/v3/gamepad.js"></script>
|
||||
<script defer src="/static/v3/profile.js"></script>
|
||||
<script defer src="/static/v3/progress.js"></script>
|
||||
<script defer src="/static/v3/shop.js"></script>
|
||||
@@ -1328,7 +1321,6 @@
|
||||
the cover picker (window.__fbOpenImagePicker). -->
|
||||
<script defer src="/static/v3/image-picker.js"></script>
|
||||
<script defer src="/static/v3/songs.js"></script>
|
||||
<script defer src="/static/v3/gamepad-nav.js"></script>
|
||||
<script defer src="/static/v3/lessons.js"></script>
|
||||
<script defer src="/static/v3/dashboard.js"></script>
|
||||
<script defer src="/static/v3/settings.js"></script>
|
||||
|
||||
+4
-236
@@ -53,192 +53,12 @@
|
||||
return (m && m.index != null) ? m.index : null;
|
||||
}
|
||||
|
||||
// ── Playlist tuning check ────────────────────────────────────────────────
|
||||
// Playlists are commonly grouped BY TUNING so a practice run needs no
|
||||
// retune mid-session (retuning a bass is minutes of settling, and detuning
|
||||
// far on standard gauges goes floppy). A playlist built before the tuning
|
||||
// filter knew about your instrument can hold songs you can't actually play
|
||||
// without stopping. This flags them. It is READ-ONLY: nothing here edits a
|
||||
// playlist — removal is a separate, explicit, itemised action.
|
||||
|
||||
// Pick the indexed perspective that matches the player's live instrument.
|
||||
// #1003 supplies bass-specific columns; when a song has no bass chart we
|
||||
// deliberately fall back to the historical song-level guitar tuning.
|
||||
function rowTuningForCheck(s) {
|
||||
let wantsBass = false;
|
||||
try {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
const cur = wt && typeof wt.get === 'function' ? wt.get() : null;
|
||||
wantsBass = !!cur && cur.instrument === 'bass';
|
||||
} catch (_) { /* capability errors degrade to the song-level tuning */ }
|
||||
const hasBassTuning = wantsBass && !!s.bass_tuning_offsets;
|
||||
return {
|
||||
offsets: hasBassTuning
|
||||
? s.bass_tuning_offsets : (s.tuning_offsets || s.tuning_name),
|
||||
// The selected bass perspective uses bass base pitches. A bass-only
|
||||
// fallback row does too; every other fallback is the lead chart.
|
||||
isBass: hasBassTuning || !!s.bass_only,
|
||||
};
|
||||
}
|
||||
// A coverage report says "not covered" BOTH for a real mismatch and for
|
||||
// "I couldn't work it out" (missing settings/tuner data → an all-empty
|
||||
// report). Only a report carrying an actual reason — named string changes,
|
||||
// a reference-pitch gap, or too few strings — is a mismatch. An unexplained
|
||||
// not-covered is UNKNOWN. A false "wrong tuning" on a hand-curated playlist
|
||||
// costs more trust than saying nothing.
|
||||
function tuningStateFromReport(rep) {
|
||||
if (!rep) return 'unknown';
|
||||
if (rep.covered) return 'match';
|
||||
if (rep.cantCover || rep.reference
|
||||
|| (Array.isArray(rep.retune) && rep.retune.length)) return 'mismatch';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Score every row. Returns null when the host exposes no tuning perspective
|
||||
// at all (no working-tuning capability / no tuner coverage) — the caller
|
||||
// then renders the playlist exactly as before rather than claiming anything.
|
||||
async function checkPlaylistTuning(songs) {
|
||||
const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
|
||||
const hasWT = window.feedBack && window.feedBack.workingTuning
|
||||
&& typeof window.feedBack.workingTuning.get === 'function';
|
||||
if (typeof cov !== 'function' || !hasWT) return null;
|
||||
const parse = window.parseRawTuningOffsets;
|
||||
const out = [];
|
||||
for (const s of songs || []) {
|
||||
const t = rowTuningForCheck(s);
|
||||
const offs = (typeof parse === 'function') ? parse(t.offsets) : null;
|
||||
if (!offs || !offs.length || offs.some((n) => !isFinite(n))) {
|
||||
out.push({ song: s, state: 'unknown' });
|
||||
continue;
|
||||
}
|
||||
let rep = null;
|
||||
try {
|
||||
rep = await cov({
|
||||
tuning: offs, stringCount: offs.length,
|
||||
arrangement: t.isBass ? 'Bass' : 'Lead',
|
||||
});
|
||||
} catch (_) { rep = null; }
|
||||
out.push({ song: s, state: tuningStateFromReport(rep) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Colour + a TEXT marker per state — unknown is deliberately neutral-and-
|
||||
// dimmed rather than amber, because "I couldn't check this" is a different
|
||||
// claim from "this is the wrong tuning" and must not read as the latter.
|
||||
function paintTuningChip(chip, state) {
|
||||
if (!chip) return;
|
||||
if (chip.dataset.baseTitle == null) chip.dataset.baseTitle = chip.getAttribute('title') || '';
|
||||
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400', 'opacity-60');
|
||||
chip.classList.add(state === 'match' ? 'bg-emerald-500'
|
||||
: state === 'mismatch' ? 'bg-amber-400' : 'bg-fb-mid');
|
||||
if (state === 'unknown') chip.classList.add('opacity-60');
|
||||
chip.setAttribute('title', chip.dataset.baseTitle + (state === 'match'
|
||||
? ' — matches your tuning'
|
||||
: state === 'mismatch' ? ' — needs a retune'
|
||||
: ' — no tuning data, not checked'));
|
||||
// Never signal by colour alone.
|
||||
const mark = state === 'mismatch' ? ' ⚠' : state === 'unknown' ? ' ?' : '';
|
||||
let m = chip.querySelector('[data-tuning-mark]');
|
||||
if (!m) {
|
||||
m = document.createElement('span');
|
||||
m.setAttribute('data-tuning-mark', '');
|
||||
chip.appendChild(m);
|
||||
}
|
||||
m.textContent = mark;
|
||||
}
|
||||
|
||||
function tuningSummaryHtml(results) {
|
||||
const total = results.length;
|
||||
if (!total) return '';
|
||||
const mism = results.filter((r) => r.state === 'mismatch').length;
|
||||
const unk = results.filter((r) => r.state === 'unknown').length;
|
||||
// Plain gap-3 rather than gap-x-3/gap-y-2: the axis-specific pair isn't
|
||||
// in the committed tailwind.min.css, and regenerating it is not
|
||||
// reproducible outside CI (autoprefixer/caniuse drift changes unrelated
|
||||
// bytes), so the summary bar stays within the shipped class set.
|
||||
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-3 ';
|
||||
if (!mism) {
|
||||
return '<div class="' + box + 'border-fb-good/40 bg-fb-good/30 text-fb-good">' +
|
||||
'<span>✓ All ' + total + ' songs are in your tuning.</span>' +
|
||||
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data).</span>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
return '<div class="' + box + 'border-amber-400/40 bg-amber-400/10 text-fb-text">' +
|
||||
'<span><strong>' + mism + '</strong> of ' + total + ' songs aren\'t in your tuning.</span>' +
|
||||
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data) — left alone.</span>' : '') +
|
||||
'<span class="flex-1"></span>' +
|
||||
'<button id="v3-pl-tune-only" class="text-xs px-2 py-1 rounded border border-fb-border text-fb-textDim hover:text-fb-text" aria-pressed="false">Show only these</button>' +
|
||||
'<button id="v3-pl-tune-remove" class="text-xs px-2 py-1 rounded border border-amber-400/40 text-fb-text hover:bg-fb-card">Remove them…</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Run the check and wire its affordances. Read-only: the only mutation is
|
||||
// the explicit, itemised, confirmed removal below.
|
||||
async function applyTuningCheck(root, pl, pid, rerender) {
|
||||
const host = root.querySelector('#v3-pl-tuning');
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (!host || !listEl) return;
|
||||
const results = await checkPlaylistTuning(pl.songs);
|
||||
if (!results) return; // no perspective → say nothing
|
||||
const rows = listEl.querySelectorAll('li[data-fn]');
|
||||
results.forEach((r, i) => {
|
||||
const li = rows[i];
|
||||
if (!li) return;
|
||||
li.setAttribute('data-tuning-state', r.state);
|
||||
paintTuningChip(li.querySelector('[data-tuning-chip]'), r.state);
|
||||
});
|
||||
host.innerHTML = tuningSummaryHtml(results);
|
||||
|
||||
const onlyBtn = host.querySelector('#v3-pl-tune-only');
|
||||
onlyBtn?.addEventListener('click', () => {
|
||||
const on = onlyBtn.getAttribute('aria-pressed') !== 'true';
|
||||
onlyBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
onlyBtn.textContent = on ? 'Show all' : 'Show only these';
|
||||
rows.forEach((li) => {
|
||||
li.classList.toggle('hidden', on && li.getAttribute('data-tuning-state') !== 'mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
host.querySelector('#v3-pl-tune-remove')?.addEventListener('click', async () => {
|
||||
// Name every song BEFORE removing anything — a curated playlist is
|
||||
// user data, so the confirm has to be a list, not a count.
|
||||
const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song);
|
||||
if (!doomed.length) return;
|
||||
const names = doomed.map((s) => '<div>• ' + esc(s.title || s.filename) + '</div>').join('');
|
||||
const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's')
|
||||
+ ' from "' + esc(pl.name) + '"?'
|
||||
// Bulleted with a literal •, and sized with max-h-32, so the
|
||||
// confirm needs no Tailwind class the committed CSS lacks —
|
||||
// regenerating tailwind.min.css is not reproducible off CI.
|
||||
+ '<div class="mt-2 text-xs max-h-32 overflow-y-auto">' + names + '</div>'
|
||||
+ '<p class="text-xs text-fb-textDim mt-2">They stay in your library — only this playlist changes, and you can add them back.</p>';
|
||||
const ok = (typeof window.uiConfirm === 'function')
|
||||
? await window.uiConfirm({
|
||||
title: 'Remove mismatched songs?', html: msg,
|
||||
confirmText: 'Remove ' + doomed.length, cancelText: 'Cancel', danger: true,
|
||||
})
|
||||
: window.confirm('Remove ' + doomed.length + ' song(s) from "' + pl.name + '"?\n\n'
|
||||
+ doomed.map((s) => '• ' + (s.title || s.filename)).join('\n')
|
||||
+ '\n\nThey stay in your library.');
|
||||
if (!ok) return;
|
||||
for (const s of doomed) {
|
||||
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(s.filename),
|
||||
{ method: 'DELETE' });
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
function songRow(s, opts) {
|
||||
opts = opts || {};
|
||||
const handle = opts.draggable
|
||||
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
|
||||
// The chip carries its own tuning so the post-paint check can colour it
|
||||
// in place (green = play it now, amber = needs a retune, dimmed ? =
|
||||
// couldn't tell) without re-rendering the list.
|
||||
const tuning = s.tuning_name
|
||||
? '<span data-tuning-chip class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm" title="' + esc(s.tuning_name) + '">' + esc(s.tuning_name) + '</span>' : '';
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
|
||||
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
|
||||
// the work's current keeper when the pinned file is gone) with its
|
||||
@@ -324,29 +144,19 @@
|
||||
const root = document.getElementById('v3-playlists');
|
||||
if (!root) return;
|
||||
const lists = (await jget('/api/playlists')) || [];
|
||||
// Drag-to-reorder is for user playlists only — system ones (Saved for
|
||||
// Later) stay pinned first by the server ordering.
|
||||
const userCount = lists.filter((p) => !p.system_key).length;
|
||||
root.innerHTML =
|
||||
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
|
||||
'<div class="flex items-center justify-end gap-2 mb-6">' +
|
||||
// Sort A–Z: clears the manual (drag) order server-side. Only worth
|
||||
// showing once there are two user playlists to order.
|
||||
(userCount > 1
|
||||
? '<button id="v3-pl-sort-az" title="Sort playlists alphabetically (clears manual order)" class="text-sm text-fb-textDim hover:text-fb-text px-2">Sort A–Z</button>' : '') +
|
||||
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
|
||||
// per track — same machinery as a playlist, kind='album'.
|
||||
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
|
||||
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
|
||||
'</div>' +
|
||||
(lists.length
|
||||
? '<div id="v3-pl-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||||
'<button data-pl="' + p.id + '"' + (p.system_key ? '' : ' draggable="true"') + ' class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||||
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
playlistCoverHtml(p) +
|
||||
'<div class="flex items-center gap-1">' +
|
||||
'<span class="flex-1 min-w-0 text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</span>' +
|
||||
(p.system_key ? '' : '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>') +
|
||||
'</div>' +
|
||||
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>'
|
||||
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
|
||||
@@ -363,44 +173,8 @@
|
||||
await jsend('POST', '/api/playlists', { name, kind: 'album' });
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => {
|
||||
await jsend('POST', '/api/playlists/sort-alpha');
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelectorAll('[data-pl]').forEach((b) =>
|
||||
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
|
||||
// Drag-reorder of the playlist cards themselves (mirrors wireSongRows).
|
||||
// Only user playlists carry draggable="true"; system cards are neither
|
||||
// drag sources nor drop targets, so nothing can be inserted ahead of
|
||||
// them (and the server pins them first regardless).
|
||||
const grid = root.querySelector('#v3-pl-grid');
|
||||
if (grid) {
|
||||
let dragEl = null;
|
||||
grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => {
|
||||
card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); });
|
||||
card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); });
|
||||
card.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragEl || dragEl === card) return;
|
||||
// Grid tiles flow left→right then wrap, so the insert side
|
||||
// is horizontal (the song rows' vertical-midpoint idiom,
|
||||
// rotated); moving to another row targets that row's cards.
|
||||
const rect = card.getBoundingClientRect();
|
||||
const after = (e.clientX - rect.left) > rect.width / 2;
|
||||
card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card);
|
||||
});
|
||||
card.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]'))
|
||||
.map((x) => parseInt(x.getAttribute('data-pl'), 10));
|
||||
await jsend('POST', '/api/playlists/reorder', { order });
|
||||
// Re-sync from the server: if /reorder was rejected
|
||||
// (concurrent change) or the request failed, the optimistic
|
||||
// DOM order would otherwise diverge from what persisted.
|
||||
renderPlaylists();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPlaylistDetail(pid) {
|
||||
@@ -452,9 +226,6 @@
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
meter +
|
||||
// Filled in after paint by applyTuningCheck (async, feature-detected)
|
||||
// — stays empty when the host exposes no tuning perspective.
|
||||
(pl.songs.length ? '<div id="v3-pl-tuning"></div>' : '') +
|
||||
(pl.songs.length
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||
@@ -504,9 +275,6 @@
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||
// Post-paint so the list is interactive immediately; a per-song coverage
|
||||
// call can await the tuner plugin's settings fetch.
|
||||
if (listEl) applyTuningCheck(root, pl, pid, () => renderPlaylistDetail(pid));
|
||||
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
|
||||
if (listEl && isAlbum) {
|
||||
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
|
||||
|
||||
+4138
-4455
File diff suppressed because it is too large
Load Diff
@@ -133,11 +133,6 @@
|
||||
let _lastStingerAt = -Infinity;
|
||||
let _prevStreak = 0;
|
||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
||||
// Filename of the song song:loaded last reported. An arrangement switch
|
||||
// re-emits song:loaded for the SAME file (changeArrangement reloads through
|
||||
// the normal load path), and that must not be mistaken for arriving at the
|
||||
// venue with a new song — see onSongLoaded.
|
||||
let _lastSongFile = '';
|
||||
let _bound = false;
|
||||
|
||||
function now() { return Date.now(); }
|
||||
@@ -483,40 +478,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// song:loaded for the SAME file is an arrangement switch, not an arrival at
|
||||
// the venue. changeArrangement() reloads through the normal load path, so
|
||||
// the event is indistinguishable from a fresh load except by filename.
|
||||
function isArrangementSwitch(prevFile, nextFile) {
|
||||
return !!nextFile && nextFile === prevFile;
|
||||
}
|
||||
|
||||
function onSongLoaded(song) {
|
||||
const file = String((song && song.filename) || '');
|
||||
const sameSong = isArrangementSwitch(_lastSongFile, file);
|
||||
_lastSongFile = file;
|
||||
|
||||
function onSongLoaded() {
|
||||
machine.reset();
|
||||
_prevStreak = 0;
|
||||
_lastAccuracyPct = null;
|
||||
|
||||
// Switching arrangement is NOT arriving at the venue.
|
||||
//
|
||||
// changeArrangement() reloads the song through the same path as a fresh
|
||||
// load, so highway.js emits song:loaded again — same filename, new
|
||||
// arrangement. Treated as a new song, that replayed the arrival flyover:
|
||||
// the camera flew in from the back of the room again mid-set, every time
|
||||
// the player switched from lead to rhythm. The player is already on
|
||||
// stage; the room should just carry on.
|
||||
//
|
||||
// So keep the video pipeline running and only re-sync the mood: the
|
||||
// performance restarts, so the loop must follow the reset machine (a
|
||||
// quiet crossfade), never the intro.
|
||||
if (sameSong) {
|
||||
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// A genuinely different song — full teardown.
|
||||
// Abort any stinger/pending state from the previous song: its ended
|
||||
// handler must not fade back into the old song's layers.
|
||||
cancelFade();
|
||||
@@ -529,27 +494,7 @@
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
if (_venueActive && _manifest) {
|
||||
// The flyover is ARRIVING at the venue, and you arrive once. Songs
|
||||
// 2..N of a set (a gig / album / playlist) are a NEW song but the
|
||||
// SAME arrival — the camera should not fly in from the back of the
|
||||
// room before every track (tester: "it showed the flyover intro
|
||||
// again" on a gig's second song). Continue the room to the new song's
|
||||
// loop; only a first-song / standalone arrival flies in.
|
||||
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
|
||||
else if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Is this song load a continuation of a play queue (a set already in
|
||||
// progress), rather than an arrival? True for song 2..N of a gig/album/
|
||||
// playlist. The queue owns the answer; treat any error / absent queue as
|
||||
// "not a continuation" so a standalone play still flies in.
|
||||
function _isSetContinuation() {
|
||||
try {
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
|
||||
} catch (_) {
|
||||
return false;
|
||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,7 +651,6 @@
|
||||
bindRuntime,
|
||||
getState,
|
||||
celebrate,
|
||||
isArrangementSwitch,
|
||||
};
|
||||
|
||||
if (root) root.v3VenueCrowd = api;
|
||||
|
||||
@@ -18,30 +18,6 @@
|
||||
let _lastMood = 'idle';
|
||||
let _bound = false;
|
||||
|
||||
// The venue belongs to the SONG player and nowhere else.
|
||||
//
|
||||
// isVenueViz() only answers "is Venue the selected visualization" — a global
|
||||
// preference. It says nothing about what is on screen. Other surfaces borrow
|
||||
// the same highway_3d renderer (Virtuoso runs its practice charts on it), so
|
||||
// with Venue selected they inherited the venue backdrop: the crowd and the
|
||||
// stage showed up behind a chromatic exercise. The viz picker is a
|
||||
// preference for the player; it is not a licence to paint the venue over
|
||||
// whatever else happens to be using the renderer.
|
||||
//
|
||||
// So gate on both: Venue selected AND the player screen is the one showing.
|
||||
function isPlayerScreen() {
|
||||
try {
|
||||
const active = document.querySelector('.screen.active');
|
||||
return !!active && active.id === 'player';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldBeActive() {
|
||||
return isVenueViz() && isPlayerScreen();
|
||||
}
|
||||
|
||||
function isVenueViz() {
|
||||
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
||||
const sel = root.v3VenueViz.getSelectedVizId
|
||||
@@ -170,8 +146,7 @@
|
||||
|
||||
function syncViz(vizId) {
|
||||
const id = String(vizId || '');
|
||||
// Venue selected is necessary but not sufficient — see shouldBeActive.
|
||||
if (id === 'venue' && isPlayerScreen()) {
|
||||
if (id === 'venue') {
|
||||
activate();
|
||||
} else {
|
||||
deactivate();
|
||||
@@ -217,19 +192,12 @@
|
||||
if (_active) syncInstrumentPov();
|
||||
});
|
||||
sm.on('viz:renderer:ready', () => {
|
||||
if (shouldBeActive()) activate();
|
||||
if (isVenueViz()) activate();
|
||||
else deactivate();
|
||||
});
|
||||
sm.on('viz:reverted', () => deactivate());
|
||||
// Leaving the player tears the venue down; coming back rebuilds it.
|
||||
// Without this the backdrop followed the renderer onto every other
|
||||
// surface that borrows it (Virtuoso's practice highway).
|
||||
sm.on('screen:changed', () => {
|
||||
if (shouldBeActive()) activate();
|
||||
else deactivate();
|
||||
});
|
||||
}
|
||||
if (shouldBeActive()) activate();
|
||||
if (isVenueViz()) activate();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
@@ -266,8 +234,6 @@
|
||||
activate,
|
||||
deactivate,
|
||||
syncViz,
|
||||
isPlayerScreen,
|
||||
shouldBeActive,
|
||||
onAssetsLoaded,
|
||||
onAssetsFailed,
|
||||
onPerformanceState,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// The window globals are a THIRD-PARTY CONTRACT. Pin them.
|
||||
//
|
||||
// Out-of-tree plugins load their screen.js as a CLASSIC script and call these
|
||||
// as bare globals. Nothing in core reads most of them, so a call-graph scan,
|
||||
// ESLint's no-undef, and a grep all come back clean while the plugin breaks in
|
||||
// the field. This is the frontend twin of tests/test_plugin_context_contract.py
|
||||
// — same reasoning, same literal-list rule.
|
||||
//
|
||||
// This guard is retroactive: `esc` was an implicit global back when app.js was
|
||||
// a classic script, went module-scoped in a9fce29, and got carved into
|
||||
// js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt
|
||||
// without it, and the MIDI plugin's device list threw "esc is not defined" for
|
||||
// testers — reported as "MIDI Access denied", because the ReferenceError landed
|
||||
// in a try/catch meant for permission failures.
|
||||
//
|
||||
// WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from
|
||||
// app.js would assert the code equals itself. The point is that a human has to
|
||||
// look at a diff and consciously agree to change the contract.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const PLUGIN_GLOBALS = [
|
||||
'_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl',
|
||||
'_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal',
|
||||
'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop',
|
||||
'deleteSelectedLoop', 'esc', 'exportDiagnostics', 'exportSettings', 'filterFavorites',
|
||||
'filterLibrary', 'fullRescanLibrary', 'goFavPage', 'handleSliderInput',
|
||||
'hideScanBanner', 'importSettings', 'loadPlugins', 'loadSavedLoop',
|
||||
'loadSettings', 'onSectionPracticeModeChange', 'openEditModal', 'persistSetting',
|
||||
'pickDlcFolder', 'pinCurrentArrangementDefault', 'playSong', 'previewDiagnostics',
|
||||
'previewEditArt', 'renderGridCards', 'renderTreeInto', 'rescanLibrary',
|
||||
'retuneSong', 'saveCurrentLoop', 'saveSettings', 'seekBy',
|
||||
'setAvOffsetMs', 'setFavView', 'setInstrumentPathway', 'setLibView',
|
||||
'setLibraryProvider', 'setLoopEnd', 'setLoopStart', 'setMastery',
|
||||
'setSpeed', 'setViz', 'showScreen', 'sortFavorites',
|
||||
'sortLibrary', 'syncLibrarySong', 'toggleAllArtists', 'toggleAllFavoriteArtists',
|
||||
'toggleLibFilters', 'togglePlay', 'toggleSectionPracticePopover', 'uiPrompt',
|
||||
'updatePlugin', 'uploadSongs',
|
||||
'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage',
|
||||
];
|
||||
|
||||
test('plugin-facing window globals are all callable', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const missing = await page.evaluate(
|
||||
(names) => names.filter((n) => typeof (window as any)[n] !== 'function'),
|
||||
PLUGIN_GLOBALS,
|
||||
);
|
||||
|
||||
expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// The plugin call site that actually broke: esc() interpolated into a template
|
||||
// string. A global that exists but doesn't escape is its own bug.
|
||||
test('window.esc escapes HTML metacharacters', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const escaped = await page.evaluate(() => (window as any).esc('<img src=x onerror=alert(1)>'));
|
||||
expect(escaped).not.toContain('<img');
|
||||
expect(escaped).toContain('<');
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// Unit tests for the App-updates panel's status → view-model state machine
|
||||
// (_appUpdateStatusView in static/js/settings.js). settings.js pulls a large
|
||||
// ES-module graph (highway-colors, library, player-controls), so rather than
|
||||
// import it, the pure function is sliced out of source and evaluated on its
|
||||
// own — it's DOM-free by construction, which is the whole point of extracting
|
||||
// it. The slice marker is asserted so a rename fails loudly instead of testing
|
||||
// nothing.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'settings.js'), 'utf8');
|
||||
|
||||
function extractFn(source, name) {
|
||||
const marker = `export function ${name}`;
|
||||
const start = source.indexOf(marker);
|
||||
assert.notEqual(start, -1, `${name} must exist in settings.js`);
|
||||
// Skip the parameter list first (it contains a destructured `{ … } = {}`
|
||||
// default, so the body's opening brace isn't the first `{` after the name).
|
||||
let pd = 0, i = source.indexOf('(', start);
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === '(') pd++;
|
||||
else if (source[i] === ')' && --pd === 0) break;
|
||||
}
|
||||
const open = source.indexOf('{', i);
|
||||
let depth = 0;
|
||||
for (let j = open; j < source.length; j++) {
|
||||
if (source[j] === '{') depth++;
|
||||
else if (source[j] === '}' && --depth === 0) {
|
||||
return source.slice(start, j + 1).replace('export function', 'function');
|
||||
}
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
const _appUpdateStatusView = new Function(
|
||||
`${extractFn(SRC, '_appUpdateStatusView')}\nreturn _appUpdateStatusView;`,
|
||||
)();
|
||||
|
||||
const FMT = () => 'just now';
|
||||
const view = (s, opts) => _appUpdateStatusView(s, { channelValue: 'nightly', fmtTimestamp: FMT, ...opts });
|
||||
|
||||
test('null status → unavailable', () => {
|
||||
assert.deepEqual(_appUpdateStatusView(null), { kind: 'unavailable' });
|
||||
});
|
||||
|
||||
test('unsupported / any linux-platform status → unsupported', () => {
|
||||
assert.equal(view({ status: 'unsupported', platform: 'linux' }).kind, 'unsupported');
|
||||
assert.equal(view({ status: 'idle', platform: 'linux' }).kind, 'unsupported',
|
||||
'a stray platform:linux still routes to the fallback, matching renderFrom');
|
||||
});
|
||||
|
||||
test('idle shows "up to date" with the formatted last-checked time, controls enabled', () => {
|
||||
const v = view({ status: 'idle', currentVersion: '1.2.3', channel: 'nightly', lastChecked: 123 });
|
||||
assert.equal(v.kind, 'status');
|
||||
assert.equal(v.line, 'Version 1.2.3 · nightly · up to date · last checked just now');
|
||||
assert.equal(v.btnLabel, 'Check for updates');
|
||||
assert.equal(v.btnMode, 'check');
|
||||
assert.equal(v.btnDisabled, false);
|
||||
assert.equal(v.channelDisabled, false);
|
||||
});
|
||||
|
||||
test('checking and downloading disable the button AND lock the channel selector', () => {
|
||||
const chk = view({ status: 'checking', currentVersion: '1', channel: 'nightly' });
|
||||
assert.equal(chk.btnDisabled, true);
|
||||
assert.equal(chk.channelDisabled, true);
|
||||
assert.match(chk.line, /checking for updates…$/);
|
||||
|
||||
const dl = view({ status: 'downloading', currentVersion: '1', channel: 'nightly', percent: 42 });
|
||||
assert.equal(dl.btnDisabled, true);
|
||||
assert.equal(dl.channelDisabled, true);
|
||||
assert.match(dl.line, /downloading update… 42%$/);
|
||||
});
|
||||
|
||||
test('downloading without a percent falls back to the indeterminate label', () => {
|
||||
const v = view({ status: 'downloading', currentVersion: '1', channel: 'nightly', percent: null });
|
||||
assert.match(v.line, /update available — downloading…$/);
|
||||
});
|
||||
|
||||
test('downloaded flips the button to Restart when apply() exists', () => {
|
||||
const v = view({ status: 'downloaded', currentVersion: '1', channel: 'nightly' }, { canApply: true });
|
||||
assert.equal(v.btnLabel, 'Restart now');
|
||||
assert.equal(v.btnMode, 'restart');
|
||||
assert.equal(v.channelDisabled, false, 'staged is not in-flight — channel stays switchable');
|
||||
assert.match(v.line, /update ready$/);
|
||||
});
|
||||
|
||||
test('downloaded on an older bridge (no apply) stays a plain check button with text-only guidance', () => {
|
||||
const v = view({ status: 'downloaded', currentVersion: '1', channel: 'nightly' }, { canApply: false });
|
||||
assert.equal(v.btnMode, 'check');
|
||||
assert.equal(v.btnLabel, 'Check for updates');
|
||||
assert.match(v.line, /update ready — restart to apply$/);
|
||||
});
|
||||
|
||||
test('error surfaces the message, or a generic fallback when absent', () => {
|
||||
assert.match(view({ status: 'error', currentVersion: '1', channel: 'nightly', message: 'boom' }).line, /update error: boom$/);
|
||||
assert.match(view({ status: 'error', currentVersion: '1', channel: 'nightly' }).line, /update check failed$/);
|
||||
});
|
||||
|
||||
test('missing version and channel fall back to "?" and the dropdown value', () => {
|
||||
const v = view({ status: 'idle', lastChecked: 0 }, { channelValue: 'beta' });
|
||||
assert.match(v.line, /^Version \? · beta · /);
|
||||
});
|
||||
@@ -116,30 +116,3 @@ test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
|
||||
// feedBack#… (tester): "Venue doesn't load when starting song from passport.
|
||||
// Loads standard particles." 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 that tab, so refresh()
|
||||
// never runs during it — the venue viz turns on but its crowd/stage pack never
|
||||
// loads. startGig must push the manifest itself after setting the override.
|
||||
test('startGig pushes the crowd manifest for the gig venue', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8');
|
||||
const start = src.indexOf('async function startGig(');
|
||||
assert.ok(start !== -1, 'startGig not found');
|
||||
const open = src.indexOf('{', src.indexOf(')', start));
|
||||
let depth = 1, i = open + 1;
|
||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
|
||||
const fn = src.slice(start, i);
|
||||
// The override is set, then the manifest must be (re)pushed for it.
|
||||
const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/);
|
||||
const pushIdx = fn.search(/pushCrowdManifest\s*\(/);
|
||||
assert.ok(overrideIdx !== -1, 'startGig must set the venue override');
|
||||
assert.ok(pushIdx !== -1,
|
||||
'startGig must push the crowd manifest — refresh() (its only other caller) ' +
|
||||
'never runs during a gig, so the venue pack would never load');
|
||||
assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue');
|
||||
});
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const CHART_TRANSFORM_JS = path.join(ROOT, 'static', 'capabilities', 'chart-transform.js');
|
||||
|
||||
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
|
||||
const PLUGIN_ID = 'example_plugin';
|
||||
const PROVIDER_ID = 'example-transform';
|
||||
const PROVIDER_LABEL = 'Example Transform';
|
||||
|
||||
function makeFakeHighway() {
|
||||
const calls = { set: [], refresh: 0 };
|
||||
return {
|
||||
calls,
|
||||
setChartTransform(p) { calls.set.push(p); },
|
||||
refreshChartTransform() { calls.refresh += 1; },
|
||||
getChartTransform() { return calls.set.length ? calls.set[calls.set.length - 1] : null; },
|
||||
};
|
||||
}
|
||||
|
||||
function loadChartTransform(options = {}) {
|
||||
const window = createWindow(options);
|
||||
// The real bus provides feedBack.on; the harness only has emit →
|
||||
// dispatchEvent. Shim `on` the same way app.js implements it so the
|
||||
// module's bus mirroring (song:ready, chart-transform-failed) is live.
|
||||
window.feedBack.on = (type, handler) => window.addEventListener(type, handler);
|
||||
if (options.highway) window.highway = options.highway;
|
||||
if (options.persistedSelection) window.localStorage.setItem(STORAGE_KEY, options.persistedSelection);
|
||||
const context = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
|
||||
vm.runInContext(fs.readFileSync(CHART_TRANSFORM_JS, 'utf8'), context, { filename: CHART_TRANSFORM_JS });
|
||||
return window;
|
||||
}
|
||||
|
||||
function captureEvents(api, eventNames) {
|
||||
const events = [];
|
||||
for (const name of eventNames) {
|
||||
api.subscribe(name, (detail) => events.push(detail));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function registerProvider(api, overrides = {}) {
|
||||
return api.dispatch({
|
||||
capability: 'chart-transform', command: 'register-provider',
|
||||
source: overrides.source || PLUGIN_ID,
|
||||
payload: {
|
||||
providerId: overrides.providerId || PROVIDER_ID,
|
||||
label: overrides.label || PROVIDER_LABEL,
|
||||
transform: overrides.transform || ((input) => ({ notes: input.notes })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('chart-transform domain registers a safe provider-coordinator owner', () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const pipeline = api.inspect('chart-transform');
|
||||
assert.ok(pipeline, 'chart-transform pipeline exists');
|
||||
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.chart-transform');
|
||||
assert.ok(owner, 'core.chart-transform owner registered');
|
||||
assert.equal(owner.safety, 'safe');
|
||||
assert.ok(owner.commands.includes('select-provider'));
|
||||
assert.ok(owner.commands.includes('refresh'));
|
||||
assert.equal(window.feedBack.chartTransformDomain.version, 1);
|
||||
});
|
||||
|
||||
test('register-provider requires a transform function', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const result = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'register-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(result.outcome, 'degraded');
|
||||
assert.match(result.reason, /transform\(input\) function/);
|
||||
});
|
||||
|
||||
test('register + select installs the provider on the highway and persists', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, [
|
||||
'chart-transform:provider-registered',
|
||||
'chart-transform:transform-changed',
|
||||
]);
|
||||
|
||||
const reg = await registerProvider(api);
|
||||
assert.equal(reg.outcome, 'handled');
|
||||
assert.ok(api.inspect('chart-transform').participants.some(p => p.pluginId === PLUGIN_ID));
|
||||
|
||||
const sel = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(sel.payload.active, PROVIDER_ID);
|
||||
assert.equal(sel.payload.installed, true);
|
||||
assert.equal(highway.calls.set.length, 1);
|
||||
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
|
||||
assert.equal(typeof highway.calls.set[0].transform, 'function');
|
||||
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
|
||||
|
||||
const names = events.map(e => e.event);
|
||||
assert.ok(names.includes('provider-registered'));
|
||||
assert.ok(names.includes('transform-changed'));
|
||||
});
|
||||
|
||||
test('select-provider with an unknown id degrades', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const result = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: 'nope' },
|
||||
});
|
||||
assert.equal(result.outcome, 'degraded');
|
||||
assert.match(result.reason, /Unknown chart-transform provider/);
|
||||
});
|
||||
|
||||
test('selection without a highway is kept and installed on song:ready', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
const sel = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(sel.payload.installed, false, 'no highway yet');
|
||||
|
||||
const highway = makeFakeHighway();
|
||||
window.highway = highway;
|
||||
window.feedBack.emit('song:ready', {});
|
||||
assert.equal(highway.calls.set.length, 1);
|
||||
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
|
||||
assert.equal(window.feedBack.chartTransformDomain.snapshot().installed, true);
|
||||
});
|
||||
|
||||
test('a persisted selection restores when its provider registers', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway, persistedSelection: PROVIDER_ID });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
const snapshot = window.feedBack.chartTransformDomain.snapshot();
|
||||
assert.equal(snapshot.active, PROVIDER_ID);
|
||||
assert.equal(snapshot.activeSource, 'restore-selection');
|
||||
assert.equal(highway.calls.set.length, 1);
|
||||
});
|
||||
|
||||
test('unregister is registrant-only and detaches the active provider', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
|
||||
const denied = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: 'someone_else', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(denied.outcome, 'degraded');
|
||||
assert.match(denied.reason, /original registrant/);
|
||||
|
||||
const ok = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(ok.outcome, 'handled');
|
||||
const snapshot = window.feedBack.chartTransformDomain.snapshot();
|
||||
assert.equal(snapshot.active, null);
|
||||
assert.equal(snapshot.providers.length, 0);
|
||||
// Detach = a trailing setChartTransform(null) on the highway.
|
||||
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
|
||||
// Persisted selection survives so re-registration re-activates.
|
||||
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
|
||||
});
|
||||
|
||||
test('unregister keeps a participant while another provider still references it', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api, { providerId: 'provider-a', label: 'Provider A' });
|
||||
await registerProvider(api, { providerId: 'provider-b', label: 'Provider B' });
|
||||
|
||||
let participant = api.inspect('chart-transform').participants
|
||||
.find(p => p.pluginId === PLUGIN_ID);
|
||||
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a', 'provider-b']);
|
||||
assert.deepEqual(
|
||||
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
|
||||
[{ id: 'provider-a', label: 'Provider A' }, { id: 'provider-b', label: 'Provider B' }],
|
||||
);
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: 'provider-b' },
|
||||
});
|
||||
|
||||
participant = api.inspect('chart-transform').participants
|
||||
.find(p => p.pluginId === PLUGIN_ID);
|
||||
assert.ok(participant, 'the shared participant remains registered');
|
||||
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a']);
|
||||
assert.deepEqual(
|
||||
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
|
||||
[{ id: 'provider-a', label: 'Provider A' }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
Array.from(window.feedBack.chartTransformDomain.snapshot().providers, p => p.id),
|
||||
['provider-a'],
|
||||
);
|
||||
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'unregister-provider',
|
||||
source: PLUGIN_ID, payload: { providerId: 'provider-a' },
|
||||
});
|
||||
assert.ok(!api.inspect('chart-transform').participants
|
||||
.some(p => p.pluginId === PLUGIN_ID), 'the final removal unregisters the participant');
|
||||
});
|
||||
|
||||
test('clear-provider clears the highway hook and the persisted selection', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
const result = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui',
|
||||
});
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
|
||||
assert.equal(window.localStorage.getItem(STORAGE_KEY), null);
|
||||
});
|
||||
|
||||
test('refresh re-runs the installed transform', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
const result = await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
|
||||
assert.equal(result.outcome, 'handled');
|
||||
assert.equal(result.payload.refreshed, true);
|
||||
assert.equal(highway.calls.refresh, 1);
|
||||
});
|
||||
|
||||
test('announced highway instances (splitscreen panels) get the active transform', async () => {
|
||||
const primary = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway: primary });
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 1);
|
||||
|
||||
// A splitscreen panel announces its own createHighway() instance.
|
||||
const panel = makeFakeHighway();
|
||||
window.feedBack.emit('highway:created', { highway: panel });
|
||||
assert.equal(panel.calls.set.length, 1, 'panel receives the active transform');
|
||||
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
|
||||
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 2);
|
||||
|
||||
// Refresh reaches every surface.
|
||||
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
|
||||
assert.equal(primary.calls.refresh, 1);
|
||||
assert.equal(panel.calls.refresh, 1);
|
||||
|
||||
// Clearing detaches every surface.
|
||||
await api.dispatch({ capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui' });
|
||||
assert.equal(primary.calls.set[primary.calls.set.length - 1], null);
|
||||
assert.equal(panel.calls.set[panel.calls.set.length - 1], null);
|
||||
});
|
||||
|
||||
test('a panel announced before any selection installs on later select', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
const panel = makeFakeHighway();
|
||||
window.feedBack.emit('highway:created', { highway: panel });
|
||||
await registerProvider(api);
|
||||
const sel = await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
assert.equal(sel.outcome, 'handled');
|
||||
assert.equal(panel.calls.set.length, 1);
|
||||
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
|
||||
});
|
||||
|
||||
test('highway failure events expose a fixed public reason', async () => {
|
||||
const highway = makeFakeHighway();
|
||||
const window = loadChartTransform({ highway });
|
||||
const api = window.feedBack.capabilities;
|
||||
const events = captureEvents(api, ['chart-transform:transform-failed']);
|
||||
await registerProvider(api);
|
||||
await api.dispatch({
|
||||
capability: 'chart-transform', command: 'select-provider',
|
||||
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
|
||||
});
|
||||
|
||||
window.feedBack.emit('highway:chart-transform-failed', {
|
||||
id: PROVIDER_ID,
|
||||
reason: 'token=secret https://example.test/private chart={notes:[...]}',
|
||||
});
|
||||
|
||||
const snapshot = window.feedBack.chartTransformDomain.snapshot();
|
||||
assert.equal(snapshot.lastFailure.providerId, PROVIDER_ID);
|
||||
assert.equal(snapshot.lastFailure.reason, 'Chart transform provider failed');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].payload.reason, 'Chart transform provider failed');
|
||||
});
|
||||
|
||||
test('diagnostics contribution carries the schema and no song identity fields', async () => {
|
||||
const window = loadChartTransform();
|
||||
const api = window.feedBack.capabilities;
|
||||
await registerProvider(api);
|
||||
const contributions = window.feedBack.diagnostics.snapshotContributions();
|
||||
const diag = contributions['chart-transform-capability'];
|
||||
assert.ok(diag, 'diagnostics contributed');
|
||||
assert.equal(diag.schema, 'feedBack.chart_transform.diagnostics.v1');
|
||||
const flat = JSON.stringify(diag);
|
||||
assert.ok(!/filename|title|artist|arrangement/.test(flat), 'no song identity in diagnostics');
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
// Behavioral tests for static/v3/gamepad.js — the controller polling state
|
||||
// machine. gamepad.js is a plain IIFE with no exports, so it's loaded into a vm
|
||||
// with a fake navigator/window/document and driven frame-by-frame through a
|
||||
// manual requestAnimationFrame queue. This exercises the parts that were only
|
||||
// ever checked on a real Steam Deck: standard-mapping filtering, Steam Input's
|
||||
// duplicate-slot dedup, disconnect masking, button edge-detection, d-pad/stick
|
||||
// key-repeat timing, and the analog-stick deadzone.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad.js'), 'utf8');
|
||||
|
||||
function pad(index, opts = {}) {
|
||||
return {
|
||||
index,
|
||||
connected: opts.connected !== false,
|
||||
mapping: opts.mapping || 'standard',
|
||||
buttons: (opts.buttons || []).map(p => ({ pressed: !!p })),
|
||||
axes: opts.axes || [0, 0],
|
||||
};
|
||||
}
|
||||
|
||||
// Load a fresh gamepad.js instance with a controllable environment.
|
||||
function load() {
|
||||
let pads = [];
|
||||
const listeners = {};
|
||||
const rafQueue = [];
|
||||
const fired = []; // synthetic key codes dispatched at the focused element
|
||||
const toasts = []; // {title,...} from fbNotify.show
|
||||
let clock = 0;
|
||||
|
||||
const activeElement = { dispatchEvent(evt) { fired.push(evt.code); return true; } };
|
||||
const sandbox = {
|
||||
console: { log() {}, error() {} },
|
||||
performance: { now: () => clock },
|
||||
requestAnimationFrame: (fn) => { rafQueue.push(fn); return rafQueue.length; },
|
||||
navigator: { getGamepads: () => pads },
|
||||
KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } },
|
||||
document: {
|
||||
activeElement,
|
||||
// revealPlayerRail() looks these up; returning null makes button 3 a no-op.
|
||||
querySelector: () => null,
|
||||
},
|
||||
window: {
|
||||
addEventListener: (t, fn) => { (listeners[t] || (listeners[t] = [])).push(fn); },
|
||||
fbNotify: { show: (o) => toasts.push(o) },
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(SRC, sandbox);
|
||||
|
||||
const emit = (type, gamepad) => (listeners[type] || []).forEach(fn => fn({ gamepad }));
|
||||
return {
|
||||
setPads: (arr) => { pads = arr; },
|
||||
connect: (gp) => emit('gamepadconnected', gp),
|
||||
disconnect: (gp) => emit('gamepaddisconnected', gp),
|
||||
tick: () => { const fn = rafQueue.shift(); if (fn) fn(); },
|
||||
polling: () => rafQueue.length > 0, // a live tick re-queues itself only while polling
|
||||
setClock: (t) => { clock = t; },
|
||||
fired, toasts,
|
||||
};
|
||||
}
|
||||
|
||||
test('a non-standard pad is ignored entirely (no toast, no polling)', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { mapping: 'xbox-nonstandard' });
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
assert.equal(g.toasts.length, 0);
|
||||
assert.equal(g.polling(), false);
|
||||
});
|
||||
|
||||
test('a standard pad connecting toasts once and starts polling', () => {
|
||||
const g = load();
|
||||
const p = pad(0);
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
assert.equal(g.toasts.length, 1);
|
||||
assert.equal(g.toasts[0].title, 'Controller connected');
|
||||
assert.equal(g.polling(), true);
|
||||
});
|
||||
|
||||
test("Steam Input's duplicate virtual slots only toast once", () => {
|
||||
const g = load();
|
||||
const a = pad(0), b = pad(1);
|
||||
g.setPads([a, b]);
|
||||
g.connect(a);
|
||||
g.connect(b); // same physical controller, second XInput mirror slot
|
||||
assert.equal(g.toasts.length, 1, 'one physical controller = one toast');
|
||||
});
|
||||
|
||||
test('face buttons edge-detect: fire once per press, not once per frame', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { buttons: [true] }); // button 0 held down
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
g.tick();
|
||||
g.tick(); // still held on the next frame
|
||||
assert.deepEqual(g.fired, ['Space'], 'held button must not auto-repeat');
|
||||
|
||||
p.buttons[0].pressed = false; g.tick(); // release
|
||||
p.buttons[0].pressed = true; g.tick(); // press again
|
||||
assert.deepEqual(g.fired, ['Space', 'Space'], 'a fresh press fires again');
|
||||
});
|
||||
|
||||
test('button 1 maps to Escape; button 3 (rail reveal) fires no key', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { buttons: [false, true, false, true] });
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
g.tick();
|
||||
assert.deepEqual(g.fired, ['Escape'], 'B=Escape, Y=rail-reveal (no synthetic key)');
|
||||
});
|
||||
|
||||
test('d-pad / stick repeat: initial fire, delay, then interval repeats', () => {
|
||||
const g = load();
|
||||
const p = pad(0, { buttons: [] }); // no buttons; drive via the d-pad indices
|
||||
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
|
||||
p.buttons[13].pressed = true; // ArrowDown
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
|
||||
g.setClock(0); g.tick(); // initial press
|
||||
g.setClock(399); g.tick(); // before the 400ms repeat delay
|
||||
g.setClock(400); g.tick(); // repeat delay elapsed
|
||||
assert.deepEqual(g.fired, ['ArrowDown', 'ArrowDown'], 'one initial + one repeat at 400ms, nothing at 399ms');
|
||||
});
|
||||
|
||||
test('analog stick honors the deadzone', () => {
|
||||
const g = load();
|
||||
const p = pad(0);
|
||||
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
|
||||
g.setPads([p]);
|
||||
g.connect(p);
|
||||
|
||||
p.axes = [0, 0.4]; g.setClock(0); g.tick(); // below 0.5 deadzone → nothing
|
||||
assert.deepEqual(g.fired, [], 'sub-deadzone deflection is ignored');
|
||||
p.axes = [0.6, 0]; g.setClock(1); g.tick(); // right, past deadzone
|
||||
assert.deepEqual(g.fired, ['ArrowRight']);
|
||||
});
|
||||
|
||||
test('disconnecting one of two live slots does not stop polling or toast', () => {
|
||||
const g = load();
|
||||
const a = pad(0), b = pad(1);
|
||||
g.setPads([a, b]);
|
||||
g.connect(a); g.connect(b);
|
||||
g.toasts.length = 0;
|
||||
|
||||
b.connected = false; // Steam mirror slot drops
|
||||
g.setPads([a, b]);
|
||||
g.disconnect(b);
|
||||
assert.equal(g.toasts.length, 0, 'a still-live standard pad masks the mirror disconnect');
|
||||
assert.equal(g.polling(), true);
|
||||
});
|
||||
|
||||
test('disconnecting the last live pad stops polling and toasts', () => {
|
||||
const g = load();
|
||||
const a = pad(0);
|
||||
g.setPads([a]);
|
||||
g.connect(a);
|
||||
a.connected = false;
|
||||
g.setPads([a]);
|
||||
g.disconnect(a);
|
||||
assert.equal(g.toasts.some(t => t.title === 'Controller disconnected'), true);
|
||||
// Drain the final queued tick; polling must not re-queue itself.
|
||||
g.tick();
|
||||
assert.equal(g.polling(), false);
|
||||
});
|
||||
|
||||
test('polling acts only on the live standard pad, skipping stale/non-standard slots', () => {
|
||||
const g = load();
|
||||
const dead = pad(0, { connected: false, buttons: [true] }); // frozen, disconnected
|
||||
const raw = pad(1, { mapping: 'raw-hid', buttons: [true] }); // non-standard
|
||||
const live = pad(2, { buttons: [true] }); // standard, button 0 down
|
||||
g.setPads([dead, raw, live]);
|
||||
g.connect(live);
|
||||
g.tick();
|
||||
assert.deepEqual(g.fired, ['Space'], 'input read from the live standard pad only');
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
// Behavioral tests for static/v3/gamepad-nav.js — the generic Tab-order
|
||||
// emulation layer. Loaded into a vm with a minimal fake DOM; the module's
|
||||
// single keydown listener is captured and fed synthetic events. Covers the
|
||||
// three things it does: arrow-key focus traversal (with clamping), Enter/Space
|
||||
// activation via .click() (Chromium won't natively activate untrusted keys),
|
||||
// and the Escape "go back" fallback — plus the !isTrusted / defaultPrevented
|
||||
// gating that keeps it off real keyboard users.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad-nav.js'), 'utf8');
|
||||
|
||||
function load() {
|
||||
const state = { focused: null, clicked: [], screens: [] };
|
||||
const body = { tagName: 'BODY' };
|
||||
const cfg = { modal: null, nav: null, screen: null, backButtons: [], activeEl: body };
|
||||
let handler = null;
|
||||
|
||||
function elem(opts = {}) {
|
||||
return {
|
||||
tagName: opts.tagName || 'BUTTON',
|
||||
type: opts.type,
|
||||
isContentEditable: !!opts.isContentEditable,
|
||||
offsetParent: opts.visible === false ? null : {},
|
||||
_focusables: opts.focusables || [],
|
||||
querySelectorAll() { return this._focusables; },
|
||||
focus() { state.focused = this; },
|
||||
click() { state.clicked.push(this); },
|
||||
};
|
||||
}
|
||||
|
||||
const document = {
|
||||
body,
|
||||
get activeElement() { return cfg.activeEl; },
|
||||
addEventListener(type, fn) { if (type === 'keydown') handler = fn; },
|
||||
querySelector(sel) {
|
||||
if (sel.includes('dialog') || sel.includes('modal')) return cfg.modal;
|
||||
if (sel.includes('screen.active')) return cfg.screen;
|
||||
return null;
|
||||
},
|
||||
getElementById(id) { return id === 'v3-nav' ? cfg.nav : null; },
|
||||
querySelectorAll() { return cfg.backButtons; }, // only the Escape back-button lookup uses this
|
||||
};
|
||||
const sandbox = { document, window: { showScreen: (id) => state.screens.push(id) } };
|
||||
vm.runInNewContext(SRC, sandbox);
|
||||
|
||||
const fire = (over) => handler(Object.assign({ isTrusted: false, defaultPrevented: false, key: '' }, over));
|
||||
return { cfg, state, body, elem, fire };
|
||||
}
|
||||
|
||||
// Build a screen holding `n` visible focusables; expose them for cfg.activeEl.
|
||||
function screenWith(g, n) {
|
||||
const items = Array.from({ length: n }, () => g.elem());
|
||||
g.cfg.screen = g.elem({ focusables: items });
|
||||
g.cfg.nav = g.elem({ focusables: [] });
|
||||
return items;
|
||||
}
|
||||
|
||||
test('real keyboard input (isTrusted) is never touched', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[0];
|
||||
g.fire({ isTrusted: true, key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, null, 'trusted events must pass through untouched');
|
||||
});
|
||||
|
||||
test('a key already handled by another listener (defaultPrevented) is skipped', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[0];
|
||||
g.fire({ defaultPrevented: true, key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, null);
|
||||
});
|
||||
|
||||
test('ArrowDown/Right moves to the next focusable; ArrowUp/Left to the previous', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[1];
|
||||
g.fire({ key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, items[2], 'Down = next');
|
||||
|
||||
g.cfg.activeEl = items[1];
|
||||
g.fire({ key: 'ArrowLeft' });
|
||||
assert.equal(g.state.focused, items[0], 'Left = previous');
|
||||
});
|
||||
|
||||
test('traversal clamps at both ends', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = items[2];
|
||||
g.fire({ key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, items[2], 'no wrap past the last item');
|
||||
|
||||
g.cfg.activeEl = items[0];
|
||||
g.fire({ key: 'ArrowUp' });
|
||||
assert.equal(g.state.focused, items[0], 'no wrap before the first item');
|
||||
});
|
||||
|
||||
test('with nothing relevant focused, the first arrow lands on the first item', () => {
|
||||
const g = load();
|
||||
const items = screenWith(g, 3);
|
||||
g.cfg.activeEl = g.body; // not in the focusable list
|
||||
g.fire({ key: 'ArrowRight' });
|
||||
assert.equal(g.state.focused, items[0]);
|
||||
});
|
||||
|
||||
test('hidden focusables are skipped (offsetParent visibility)', () => {
|
||||
const g = load();
|
||||
const visibleA = g.elem();
|
||||
const hidden = g.elem({ visible: false });
|
||||
const visibleB = g.elem();
|
||||
g.cfg.screen = g.elem({ focusables: [visibleA, hidden, visibleB] });
|
||||
g.cfg.nav = g.elem({ focusables: [] });
|
||||
g.cfg.activeEl = visibleA;
|
||||
g.fire({ key: 'ArrowDown' });
|
||||
assert.equal(g.state.focused, visibleB, 'the hidden element is not a traversal stop');
|
||||
});
|
||||
|
||||
test('Enter/Space activates the focused control via click()', () => {
|
||||
const g = load();
|
||||
const btn = g.elem({ tagName: 'BUTTON' });
|
||||
g.cfg.activeEl = btn;
|
||||
g.fire({ key: 'Enter' });
|
||||
g.fire({ key: ' ' });
|
||||
assert.deepEqual(g.state.clicked, [btn, btn], 'both Enter and Space activate');
|
||||
});
|
||||
|
||||
test('activation never clicks a focused text field or the body', () => {
|
||||
const g = load();
|
||||
g.cfg.activeEl = g.elem({ tagName: 'INPUT', type: 'text' });
|
||||
g.fire({ key: 'Enter' });
|
||||
g.cfg.activeEl = g.body;
|
||||
g.fire({ key: ' ' });
|
||||
assert.deepEqual(g.state.clicked, [], 'no synthetic click into a text input or the bare body');
|
||||
});
|
||||
|
||||
test('Escape clicks the visible in-screen back button when one exists', () => {
|
||||
const g = load();
|
||||
const hiddenBack = g.elem({ visible: false }); // a back button from another, now-hidden screen
|
||||
const visibleBack = g.elem();
|
||||
g.cfg.backButtons = [hiddenBack, visibleBack];
|
||||
g.fire({ key: 'Escape' });
|
||||
assert.deepEqual(g.state.clicked, [visibleBack], 'the visible back button wins, not DOM order');
|
||||
assert.deepEqual(g.state.screens, [], 'no home fallback while a back button handled it');
|
||||
});
|
||||
|
||||
test('Escape with no visible back button falls back to the home screen', () => {
|
||||
const g = load();
|
||||
g.cfg.backButtons = [g.elem({ visible: false })];
|
||||
g.fire({ key: 'Escape' });
|
||||
assert.deepEqual(g.state.screens, ['v3-home']);
|
||||
assert.deepEqual(g.state.clicked, []);
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
// A gig is a SET, not a run of unrelated songs.
|
||||
//
|
||||
// Reported from a live gig: the player finished the first song and had to sit
|
||||
// through the per-song results popup before the next one would start, and then
|
||||
// wait again while that song was extracted from its feedpak zip.
|
||||
//
|
||||
// This file covers the CORE half — career pre-extracts the whole setlist before
|
||||
// the first note. The other half (note_detect must not show its per-song summary
|
||||
// inside a gig) lives in the note_detect plugin repo, which is not part of this
|
||||
// checkout: plugins/*/ is gitignored here and note_detect ships from
|
||||
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
|
||||
// box (where the plugin happens to be bundled) and fail in CI, which is worse
|
||||
// than no test.
|
||||
//
|
||||
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
|
||||
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
|
||||
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
assert.ok(start !== -1, `signature '${signature}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
test('startGig extracts the whole setlist before starting the queue', () => {
|
||||
const fn = extractBlock(CAREER, 'async function startGig(');
|
||||
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
||||
const startIdx = fn.search(/q\.start\s*\(/);
|
||||
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
|
||||
assert.ok(startIdx !== -1, 'q.start not found');
|
||||
assert.ok(prepIdx < startIdx,
|
||||
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
|
||||
'waits between songs, which is the bug');
|
||||
});
|
||||
|
||||
test('the stage is only borrowed once the set is ready', () => {
|
||||
const fn = extractBlock(CAREER, 'async function startGig(');
|
||||
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
||||
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
|
||||
assert.ok(prepIdx < stageIdx,
|
||||
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
|
||||
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
|
||||
'a proposal dismissed while unpacking must not then start a gig');
|
||||
});
|
||||
|
||||
test('pre-extraction never blocks the gig from starting', () => {
|
||||
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
||||
assert.match(fn, /catch\s*\(/,
|
||||
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
|
||||
});
|
||||
|
||||
test('the prepare route degrades instead of failing', () => {
|
||||
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
|
||||
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
|
||||
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
|
||||
'is an optimisation and can never be why a gig will not start');
|
||||
});
|
||||
|
||||
// ── the prepare must never be able to BLOCK the gig (CodeRabbit, #971) ──────
|
||||
//
|
||||
// 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 exact thing it
|
||||
// promises never to be: the reason you cannot play.
|
||||
|
||||
test('the prepare fetch is bounded — a hung server cannot block the gig', () => {
|
||||
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
||||
assert.match(fn, /AbortController/, 'the request must be abortable');
|
||||
assert.match(fn, /setTimeout\([\s\S]{0,40}abort\s*\(\s*\)/,
|
||||
'a hung request must be aborted, not awaited forever');
|
||||
assert.match(fn, /signal:\s*ctrl\.signal/, 'the signal must actually be passed to fetch');
|
||||
assert.match(fn, /clearTimeout/, 'the timer must be cleared on the happy path');
|
||||
assert.match(CAREER, /const\s+PREPARE_TIMEOUT_MS\s*=\s*\d+/, 'the ceiling must be named');
|
||||
// The button must be restored however we leave — otherwise a timeout strands
|
||||
// the poster on "Preparing set…" with Play disabled: unplayable.
|
||||
assert.match(fn, /finally\s*\{[\s\S]{0,220}btn\.disabled\s*=\s*false/,
|
||||
'the Play button must be re-enabled on EVERY path, including the abort');
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
// Regression coverage for the first-chart-data camera bootstrap in
|
||||
// plugins/highway_3d/screen.js.
|
||||
//
|
||||
// The event selector is pure and tested behaviourally. The renderer lifecycle
|
||||
// wiring remains source-level, matching the existing highway_3d camera tests:
|
||||
// constructing a full Three.js renderer in Node would test a large fake DOM/GL
|
||||
// harness rather than the bootstrap contract itself.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
function extractFn(source, name) {
|
||||
const start = source.indexOf('function ' + name);
|
||||
assert.ok(start >= 0, `function ${name} must exist`);
|
||||
const open = source.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
function sourceBetween(startText, endText) {
|
||||
const start = src.indexOf(startText);
|
||||
assert.ok(start >= 0, `missing source anchor: ${startText}`);
|
||||
const end = src.indexOf(endText, start);
|
||||
assert.ok(end > start, `missing source end anchor: ${endText}`);
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
const hwyFirstRelevantFrettedTime = new Function(
|
||||
'"use strict";'
|
||||
+ extractFn(src, 'hwyFirstRelevantFrettedTime')
|
||||
+ '\nreturn hwyFirstRelevantFrettedTime;',
|
||||
)();
|
||||
|
||||
test('long intros bootstrap from the earliest future fretted note', () => {
|
||||
const notes = [
|
||||
{ t: 13.22, s: 2, f: 7 },
|
||||
{ t: 15.0, s: 1, f: 4 },
|
||||
];
|
||||
const chords = [
|
||||
{ t: 14.0, notes: [{ s: 0, f: 3 }, { s: 1, f: 5 }] },
|
||||
];
|
||||
assert.equal(hwyFirstRelevantFrettedTime(notes, chords, 0.4, 0.2, 6), 13.22);
|
||||
});
|
||||
|
||||
test('chord-only charts bootstrap from fretted chord members', () => {
|
||||
const chords = [
|
||||
{ t: 4.0, notes: [{ s: 0, f: 0 }, { s: 1, f: 0 }] },
|
||||
{ t: 8.5, notes: [{ s: 0, f: 0 }, { s: 1, f: 9 }] },
|
||||
];
|
||||
assert.equal(hwyFirstRelevantFrettedTime([], chords, 0, 0.2, 6), 8.5);
|
||||
});
|
||||
|
||||
test('empty and all-open charts keep the default camera', () => {
|
||||
assert.equal(hwyFirstRelevantFrettedTime([], [], 0, 0.2, 6), null);
|
||||
assert.equal(hwyFirstRelevantFrettedTime(
|
||||
[{ t: 2, s: 0, f: 0 }],
|
||||
[{ t: 3, notes: [{ s: 1, f: 0 }, { s: 2, f: 0 }] }],
|
||||
0,
|
||||
0.2,
|
||||
6,
|
||||
), null);
|
||||
});
|
||||
|
||||
test('bootstrap ignores malformed strings but supports extended-range charts', () => {
|
||||
const notes = [
|
||||
{ t: 1, s: -1, f: 4 },
|
||||
{ t: 2, s: 7, f: 5 },
|
||||
{ t: 3, s: 6, f: 8 },
|
||||
];
|
||||
assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 6), null);
|
||||
assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 7), 3);
|
||||
});
|
||||
|
||||
test('active sustains bootstrap at now and fully expired events are skipped', () => {
|
||||
const now = 10;
|
||||
assert.equal(hwyFirstRelevantFrettedTime(
|
||||
[{ t: 6, sus: 5, s: 2, f: 7 }],
|
||||
[],
|
||||
now,
|
||||
0.2,
|
||||
6,
|
||||
), now);
|
||||
assert.equal(hwyFirstRelevantFrettedTime(
|
||||
[{ t: 6, sus: 1, s: 2, f: 7 }, { t: 15, s: 2, f: 9 }],
|
||||
[],
|
||||
now,
|
||||
0.2,
|
||||
6,
|
||||
), 15);
|
||||
});
|
||||
|
||||
test('recent onsets inside the behind-window bootstrap at now', () => {
|
||||
assert.equal(hwyFirstRelevantFrettedTime(
|
||||
[{ t: 9.9, s: 2, f: 7 }],
|
||||
[],
|
||||
10,
|
||||
0.2,
|
||||
6,
|
||||
), 10);
|
||||
});
|
||||
|
||||
test('bootstrap runs once when complete chart arrays arrive', () => {
|
||||
const bootstrap = sourceBetween(
|
||||
'// ── Camera bootstrap (first chart data)',
|
||||
' pbBeg(4);',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/if\s*\(\s*!_camSnapped\s*&&\s*!_camPreScanned\s*&&\s*notes\s*&&\s*chords\s*\)/,
|
||||
'chart bootstrap must be gated to one pass after both arrays arrive',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/hwyFirstRelevantFrettedTime\(\s*notes\s*,\s*chords\s*,\s*now\s*,\s*CAM_TGT_BEHIND\s*,\s*nStr\s*\)/,
|
||||
'bootstrap must select the first relevant event using the active string count',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/firstFrettedTime\s*===\s*null[\s\S]*?_camSnapped\s*=\s*true/,
|
||||
'all-open/empty charts without lookahead bounds must permanently disable bootstrap work',
|
||||
);
|
||||
});
|
||||
|
||||
test('steady and lookahead modes initialize immediately from future chart data', () => {
|
||||
const bootstrap = sourceBetween(
|
||||
'// ── Camera bootstrap (first chart data)',
|
||||
' pbBeg(4);',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/cameraMode\s*===\s*'lookahead'[\s\S]*?lookaheadBoundsNow\s*\|\|\s*firstFrettedTime\s*!==\s*null/,
|
||||
'lookahead anchor bounds must bootstrap even on an all-open chart',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/lookaheadBootstrapTime\(\s*now\s*,\s*firstFrettedTime\s*\)/,
|
||||
'lookahead mode must project to the first window that reaches the phrase',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/lookaheadBoundsNow\s*\?\s*now\s*:\s*lookaheadBootstrapTime/,
|
||||
'already-live anchor/note bounds must win over a projected lookahead',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/Math\.max\(\s*now\s*,\s*firstFrettedTime\s*-\s*camAhead\s*\)/,
|
||||
'steady mode must sample when the first event enters its normal target window',
|
||||
);
|
||||
assert.match(
|
||||
bootstrap,
|
||||
/curX\s*=\s*tgtX\s*;[\s\S]*?curDist\s*=\s*tgtDist\s*;/,
|
||||
'the initial base position must be applied before the note draw loop',
|
||||
);
|
||||
});
|
||||
|
||||
test('silent-intro hold hands off only when live framing is ready', () => {
|
||||
const target = sourceBetween(
|
||||
'// ── Camera target',
|
||||
'// ── Chord diagram:',
|
||||
);
|
||||
assert.match(
|
||||
target,
|
||||
/cameraMode\s*===\s*'lookahead'\s*\?\s*lookaheadBoundsNow\s*!==\s*null\s*:\s*camDistGot/,
|
||||
'lookahead and steady modes must use their own live-ready signal',
|
||||
);
|
||||
assert.match(
|
||||
target,
|
||||
/if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*prevLockActive/,
|
||||
'the bootstrap target must remain untouched while the live window is empty',
|
||||
);
|
||||
assert.match(
|
||||
target,
|
||||
/_camBootstrapMode\s*!==\s*cameraMode[\s\S]*?_camBootstrapHolding\s*=\s*false/,
|
||||
'a live camera-mode change must safely release the old-mode hold',
|
||||
);
|
||||
});
|
||||
|
||||
test('song changes and teardown reset every bootstrap state field', () => {
|
||||
const resetAssignments = src.match(
|
||||
/_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapHolding\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapMode\s*=\s*null\s*;/g,
|
||||
) || [];
|
||||
assert.equal(
|
||||
resetAssignments.length,
|
||||
2,
|
||||
'song-change and teardown paths must both reset bootstrap state',
|
||||
);
|
||||
});
|
||||
|
||||
test('Camera Director still layers after the bootstrapped auto-framing base', () => {
|
||||
const bootstrap = sourceBetween(
|
||||
'// ── Camera bootstrap (first chart data)',
|
||||
' pbBeg(4);',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
bootstrap,
|
||||
/_freeCam|__h3dCamCtl/,
|
||||
'bootstrap must only initialize base framing, never mutate Camera Director state',
|
||||
);
|
||||
|
||||
const camUpdate = extractFn(src, 'camUpdate');
|
||||
const baseIndex = camUpdate.indexOf('curX += (tgtX - curX) * lerp');
|
||||
const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)');
|
||||
const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)');
|
||||
assert.ok(
|
||||
baseIndex >= 0 && directorIndex > baseIndex && positionIndex > directorIndex,
|
||||
'Camera Director transforms must remain layered after base framing and before camera placement',
|
||||
);
|
||||
});
|
||||
@@ -1,480 +1,461 @@
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. Nearly all 3D-highway materials use
|
||||
// depthTest:false (exceptions exist — e.g. the accent halo mats set
|
||||
// depthTest:true), so renderOrder is the primary draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 3 fret inlay dots (above the lane so it no longer hides them)
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns the cached 3D highway screen source under test. */
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from screen.js. */
|
||||
function layers() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, idle tier FRET_WIRE_IDLE_HEX', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
// The wire tiers moved to named constants (feedBack#969): idle is the
|
||||
// dimmed 0x4A4A60 so the neck recedes and the anchor lane reads as the
|
||||
// focus cue. Assert the material uses the constant AND pin the constant's
|
||||
// value, so a retune is a deliberate two-line change here.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX/,
|
||||
'fret wire material must take its default color from FRET_WIRE_IDLE_HEX',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_IDLE_HEX\s*=\s*0x4A4A60/,
|
||||
'FRET_WIRE_IDLE_HEX must be the dimmed idle gray-violet 0x4A4A60',
|
||||
);
|
||||
// Both depth flags anchored to the fret-wire material literal (via its
|
||||
// FRET_WIRE_IDLE_HEX color, unique to it) — an unscoped match would pass
|
||||
// off any other depthTest:false material in the file. Asserted as two
|
||||
// separate anchored matches so property order inside the literal still
|
||||
// isn't pinned.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthTest\s*:\s*false/,
|
||||
'the fret wire material itself must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthWrite\s*:\s*false/,
|
||||
'the fret wire material itself must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire FRET_WIRE_ACTIVE_HEX (gold) for in-anchor frets, FRET_WIRE_IDLE_HEX otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_ACTIVE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_ACTIVE_HEX for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_ACTIVE_HEX\s*=\s*0xD8A636/,
|
||||
'FRET_WIRE_ACTIVE_HEX must stay the anchor-lane gold 0xD8A636',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_IDLE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_IDLE_HEX for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. All 3D-highway materials use depthTest:false, so
|
||||
// renderOrder is the *only* draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns the cached 3D highway screen source under test. */
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from screen.js. */
|
||||
function layers() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, default gray 0x666688', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*0x666688/,
|
||||
'fret wire material must have default gray color 0x666688',
|
||||
);
|
||||
// Both depth flags asserted independently so the test doesn't pin property
|
||||
// order in the material literal.
|
||||
assert.match(
|
||||
s,
|
||||
/depthTest\s*:\s*false/,
|
||||
'fret wire material must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/depthWrite\s*:\s*false/,
|
||||
'fret wire material must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire gold (0xD8A636) for in-anchor frets, gray (0x666688) otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*0xD8A636\s*\)/,
|
||||
'update() must set gold 0xD8A636 for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*0x666688\s*\)/,
|
||||
'update() must set gray 0x666688 for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
// Source-level coverage is used because createHighway's browser closure is too
|
||||
// large for the Node harness. Critical staging helpers are exercised directly.
|
||||
|
||||
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 highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const highwayDrawJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-draw.js');
|
||||
|
||||
function extractBlock(src, marker) {
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start >= 0, `${marker} present`);
|
||||
const open = src.indexOf('{', start);
|
||||
assert.ok(open >= 0, `${marker} has a body`);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth += 1;
|
||||
else if (src[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
assert.fail(`${marker} body is balanced`);
|
||||
}
|
||||
|
||||
test('highway public API exposes the chart-transform hook', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setChartTransform\s*\(\s*p\s*\)\s*\{/, 'setChartTransform exists');
|
||||
assert.match(src, /getChartTransform\s*\(\s*\)\s*\{[^}]*_xfProvider/, 'getChartTransform returns the provider');
|
||||
assert.match(src, /refreshChartTransform\s*\(\s*\)\s*\{[^}]*_restageChartTransform/, 'refreshChartTransform restages');
|
||||
});
|
||||
|
||||
test('restage runs at BOTH exits of _rebuildMasteryFilter (transform after difficulty)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fnStart = src.indexOf('function _rebuildMasteryFilter()');
|
||||
const fnEnd = src.indexOf('function _clearChartTransformStage');
|
||||
assert.ok(fnStart > -1 && fnEnd > fnStart, 'both functions present in order');
|
||||
const body = src.slice(fnStart, fnEnd);
|
||||
const calls = body.match(/_restageChartTransform\(\);/g) || [];
|
||||
assert.equal(calls.length, 2, 'restage at the early return and the normal exit');
|
||||
});
|
||||
|
||||
test('restage consumes the difficulty-filtered arrays, not the raw chart', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
assert.match(fn, /notes:\s*filterActive\s*\?\s*hwState\._filteredNotes\s*:\s*hwState\.notes/);
|
||||
assert.match(fn, /allNotes:\s*hwState\.notes/, 'full-difficulty views passed alongside');
|
||||
});
|
||||
|
||||
test('a throwing provider clears the stage and emits highway:chart-transform-failed', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
const report = extractBlock(src, 'function _reportChartTransformFailure(provider, error)');
|
||||
assert.match(fn, /catch\s*\(e\)\s*\{[\s\S]*_reportChartTransformFailure\(p, e\)[\s\S]*return;/);
|
||||
assert.match(fn, /^\s*_clearChartTransformStage\(\);/m, 'stage cleared before the provider runs');
|
||||
|
||||
// Execute the extracted reporter with a sentinel error: the emitted
|
||||
// payload must contain ONLY the approved field (id) — nothing derived
|
||||
// from the exception — while the raw error stays on the local console.
|
||||
const sandbox = { cleared: 0, emitted: [], logged: [] };
|
||||
vm.runInNewContext(`
|
||||
const _clearChartTransformStage = () => { cleared += 1; };
|
||||
const console = { error: (...args) => logged.push(args) };
|
||||
const window = { feedBack: { emit: (type, detail) => emitted.push({ type, detail }) } };
|
||||
${report}
|
||||
_reportChartTransformFailure({ id: 'prov-1' }, new Error('sentinel: /Users/someone/secret.sloppak'));
|
||||
`, sandbox);
|
||||
assert.equal(sandbox.cleared, 1, 'failure clears the stage');
|
||||
assert.equal(sandbox.emitted.length, 1, 'exactly one failure event');
|
||||
assert.equal(sandbox.emitted[0].type, 'highway:chart-transform-failed');
|
||||
assert.deepEqual(Object.keys(sandbox.emitted[0].detail), ['id'],
|
||||
'payload carries only the approved field — no exception-derived fields');
|
||||
assert.equal(sandbox.emitted[0].detail.id, 'prov-1');
|
||||
assert.ok(!JSON.stringify(sandbox.emitted[0].detail).includes('sentinel'),
|
||||
'nothing exception-derived leaks into the event');
|
||||
assert.equal(sandbox.logged.length, 1, 'raw exception stays on the local console');
|
||||
assert.ok(sandbox.logged[0].some((arg) => String(arg).includes('sentinel')),
|
||||
'the local console received the actual error');
|
||||
});
|
||||
|
||||
test('restage is a pre-ready no-op: provider stays attached, ready path runs the first staging', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
const guardAt = fn.indexOf('if (!hwState.ready) return;');
|
||||
const invokeAt = fn.indexOf('p.transform(');
|
||||
assert.ok(guardAt > -1, 'ready guard present');
|
||||
assert.ok(invokeAt > guardAt, 'guard sits before the provider is invoked');
|
||||
// The ready handler must flip hwState.ready BEFORE rebuilding the
|
||||
// filter, or the guard would skip the first real staging.
|
||||
const readyCase = src.indexOf("case 'ready':");
|
||||
const readyFlip = src.indexOf('hwState.ready = true;', readyCase);
|
||||
const readyRebuild = src.indexOf('_rebuildMasteryFilter();', readyCase);
|
||||
assert.ok(readyCase > -1 && readyFlip > -1 && readyRebuild > readyFlip,
|
||||
'ready handler sets hwState.ready before the rebuild that restages');
|
||||
});
|
||||
|
||||
test('bundle assembly prefers the staged transform views', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /b\.notes = hwState\._xfNotes !== null \? hwState\._xfNotes/);
|
||||
assert.match(src, /b\.chords = hwState\._xfChords !== null \? hwState\._xfChords/);
|
||||
assert.match(src, /b\.anchors = hwState\._xfAnchors !== null \? hwState\._xfAnchors/);
|
||||
assert.match(src, /b\.chordTemplates = hwState\._xfChordTemplates !== null/);
|
||||
assert.match(src, /b\.stringCount = hwState\._xfStringCount !== null/);
|
||||
assert.match(src, /b\.tuning = hwState\._xfTuning !== null/);
|
||||
assert.match(src, /b\.capo = hwState\._xfCapo !== null/);
|
||||
assert.match(src, /b\.handShapes = hwState\._xfHandShapes !== null \? hwState\._xfHandShapes/);
|
||||
assert.match(src, /b\.centOffset = hwState\._xfCentOffset !== null/);
|
||||
});
|
||||
|
||||
test('transform input carries the effective handShapes; output stages handShapes/centOffset', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
|
||||
assert.match(fn, /handShapes: \(hwState\._filteredHandShapes !== null && hwState\._phrasesHaveHandShapes\)/,
|
||||
'input handShapes uses the same effective selection as the bundle');
|
||||
assert.match(fn, /_sortedChartTransformArray\(out\.handShapes, 'start_time'\)/);
|
||||
assert.match(fn, /if \(Number\.isFinite\(out\.centOffset\)\) hwState\._xfCentOffset = out\.centOffset;/);
|
||||
});
|
||||
|
||||
test('unordered provider timelines are copied and normalized for searches and anchor scans', () => {
|
||||
const highwaySrc = fs.readFileSync(highwayJs, 'utf8');
|
||||
const drawSrc = fs.readFileSync(highwayDrawJs, 'utf8');
|
||||
const snippets = [
|
||||
extractBlock(highwaySrc, 'function _clearChartTransformStage()'),
|
||||
extractBlock(highwaySrc, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
|
||||
extractBlock(highwaySrc, 'function _sortedChartTransformArray(items, key)'),
|
||||
extractBlock(highwaySrc, 'function _reportChartTransformFailure(provider, error)'),
|
||||
extractBlock(highwaySrc, 'function _restageChartTransform()'),
|
||||
extractBlock(highwaySrc, 'function bsearchTime(arr, time)'),
|
||||
extractBlock(highwaySrc, 'function getAnchorAt(t)'),
|
||||
extractBlock(highwaySrc, 'function getMaxFretInWindow(t)'),
|
||||
extractBlock(drawSrc, 'export function bsearch(arr, time)').replace('export ', ''),
|
||||
].join('\n');
|
||||
const providerOutput = {
|
||||
notes: [{ t: 9 }, { t: 1 }, { t: 5 }],
|
||||
chords: [{ t: 8 }, { t: 2 }],
|
||||
anchors: [
|
||||
{ time: 10, fret: 20, width: 2 },
|
||||
{ time: 0, fret: 1, width: 3 },
|
||||
{ time: 5, fret: 10, width: 4 },
|
||||
],
|
||||
allNotes: [{ t: 7 }, { t: 0 }, { t: 3 }],
|
||||
allChords: [{ t: 6 }, { t: 4 }],
|
||||
handShapes: [{ start_time: 9 }, { start_time: 1 }],
|
||||
stringCount: 4,
|
||||
tuning: [-2, -2, -2, -2],
|
||||
capo: 2,
|
||||
centOffset: -12.5,
|
||||
};
|
||||
const hwState = {
|
||||
ready: true,
|
||||
_xfProvider: { id: 'unordered', transform: () => providerOutput },
|
||||
_filteredNotes: [],
|
||||
_filteredChords: [],
|
||||
_filteredAnchors: [],
|
||||
_filteredHandShapes: [],
|
||||
_phrasesHaveHandShapes: true,
|
||||
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
|
||||
stringCount: 6, songInfo: {},
|
||||
};
|
||||
const helpers = new Function('hwState', 'window', 'VISIBLE_SECONDS', 'console', `
|
||||
${snippets}
|
||||
return { _restageChartTransform, bsearch, bsearchTime, getAnchorAt, getMaxFretInWindow };
|
||||
`)(hwState, {}, 3, { error() {} });
|
||||
|
||||
helpers._restageChartTransform();
|
||||
|
||||
assert.deepEqual(hwState._xfNotes.map(n => n.t), [1, 5, 9]);
|
||||
assert.deepEqual(hwState._xfChords.map(ch => ch.t), [2, 8]);
|
||||
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [0, 3, 7]);
|
||||
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [4, 6]);
|
||||
assert.deepEqual(hwState._xfAnchors.map(a => a.time), [0, 5, 10]);
|
||||
assert.deepEqual(hwState._xfHandShapes.map(h => h.start_time), [1, 9]);
|
||||
assert.equal(hwState._xfStringCount, 4);
|
||||
assert.deepEqual(hwState._xfTuning, [-2, -2, -2, -2]);
|
||||
assert.equal(hwState._xfCapo, 2);
|
||||
assert.equal(hwState._xfCentOffset, -12.5);
|
||||
assert.deepEqual(providerOutput.notes.map(n => n.t), [9, 1, 5], 'provider output is not mutated');
|
||||
providerOutput.tuning[0] = 99;
|
||||
assert.equal(hwState._xfTuning[0], -2, 'staged metadata is detached from provider output');
|
||||
assert.equal(helpers.bsearch(hwState._xfNotes, 5), 1);
|
||||
assert.equal(helpers.bsearchTime(hwState._xfAnchors, 5), 1);
|
||||
assert.equal(helpers.getAnchorAt(6).time, 5);
|
||||
assert.equal(helpers.getMaxFretInWindow(0), 14);
|
||||
|
||||
hwState._filteredNotes = null;
|
||||
hwState._filteredChords = null;
|
||||
hwState._xfProvider.transform = () => ({
|
||||
notes: [{ t: 4 }, { t: 2 }],
|
||||
chords: [{ t: 3 }, { t: 1 }],
|
||||
});
|
||||
helpers._restageChartTransform();
|
||||
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [2, 4], 'unfiltered notes still fall back');
|
||||
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [1, 3], 'unfiltered chords still fall back');
|
||||
});
|
||||
|
||||
test('provider inputs and staged outputs are isolated from provider mutation', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const snippets = [
|
||||
extractBlock(src, 'function _clearChartTransformStage()'),
|
||||
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
|
||||
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
|
||||
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
|
||||
extractBlock(src, 'function _restageChartTransform()'),
|
||||
].join('\n');
|
||||
const sourceNote = { t: 1, bendValues: [{ t: 0, v: 1 }] };
|
||||
const sourceInfo = { tuning: [0, 0], nested: { value: 1 } };
|
||||
const events = [];
|
||||
const hwState = {
|
||||
ready: true,
|
||||
_xfProvider: null,
|
||||
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
|
||||
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
|
||||
notes: [sourceNote], chords: [], anchors: [], handShapes: [], chordTemplates: [],
|
||||
stringCount: 2, songInfo: sourceInfo,
|
||||
};
|
||||
const helpers = new Function('hwState', 'window', 'console', `
|
||||
${snippets}
|
||||
return { _restageChartTransform };
|
||||
`)(hwState, { feedBack: { emit(name, detail) { events.push({ name, detail }); } } }, { error() {} });
|
||||
|
||||
hwState._xfProvider = {
|
||||
id: 'mutating-provider',
|
||||
transform(input) {
|
||||
input.notes[0].t = 99;
|
||||
input.notes[0].bendValues[0].v = 7;
|
||||
input.songInfo.nested.value = 8;
|
||||
throw new Error('private provider detail');
|
||||
},
|
||||
};
|
||||
helpers._restageChartTransform();
|
||||
assert.equal(sourceNote.t, 1);
|
||||
assert.equal(sourceNote.bendValues[0].v, 1);
|
||||
assert.equal(sourceInfo.nested.value, 1);
|
||||
assert.equal(hwState._xfNotes, null);
|
||||
assert.deepEqual(events.map(event => event.name), ['highway:chart-transform-failed']);
|
||||
|
||||
const output = { notes: [{ t: 2, nested: { value: 3 } }] };
|
||||
hwState._xfProvider = { id: 'stable-provider', transform: () => output };
|
||||
helpers._restageChartTransform();
|
||||
output.notes[0].t = 20;
|
||||
output.notes[0].nested.value = 30;
|
||||
assert.equal(hwState._xfNotes[0].t, 2);
|
||||
assert.equal(hwState._xfNotes[0].nested.value, 3);
|
||||
});
|
||||
|
||||
test('async and malformed provider outputs fail closed without a partial stage', async () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const snippets = [
|
||||
extractBlock(src, 'function _clearChartTransformStage()'),
|
||||
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
|
||||
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
|
||||
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
|
||||
extractBlock(src, 'function _restageChartTransform()'),
|
||||
].join('\n');
|
||||
const events = [];
|
||||
const errors = [];
|
||||
const hwState = {
|
||||
ready: true,
|
||||
_xfProvider: { id: 'async-provider', transform: async () => { throw new Error('async detail'); } },
|
||||
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
|
||||
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
|
||||
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
|
||||
stringCount: 6, songInfo: {},
|
||||
};
|
||||
const helpers = new Function('hwState', 'window', 'console', `
|
||||
${snippets}
|
||||
return { _restageChartTransform };
|
||||
`)(hwState, { feedBack: { emit(name) { events.push(name); } } }, { error(...args) { errors.push(args); } });
|
||||
|
||||
helpers._restageChartTransform();
|
||||
assert.equal(hwState._xfNotes, null);
|
||||
assert.equal(events.length, 1);
|
||||
assert.match(String(errors[0][1]), /must return synchronously/);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.match(String(errors[1][1]), /async detail/, 'async rejection stays in the local console');
|
||||
|
||||
const output = { chords: [{ t: 1 }] };
|
||||
Object.defineProperty(output, 'notes', { enumerable: true, get() { throw new Error('bad getter'); } });
|
||||
hwState._xfProvider = { id: 'getter-provider', transform: () => output };
|
||||
helpers._restageChartTransform();
|
||||
assert.equal(hwState._xfNotes, null);
|
||||
assert.equal(hwState._xfChords, null);
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('createHighway announces each instance via highway:created', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /emit\('highway:created', \{ highway: api \}\)/,
|
||||
'factory emits highway:created with the api instance');
|
||||
});
|
||||
|
||||
test('public getters fall through transformed → filtered → raw', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getNotes\(\)\s*\{\s*return hwState\._xfNotesAll !== null/);
|
||||
assert.match(src, /getChords\(\)\s*\{\s*return hwState\._xfChordsAll !== null/);
|
||||
assert.match(src, /getFilteredNotes\(\)\s*\{\s*if \(hwState\._xfNotes !== null\) return hwState\._xfNotes;/);
|
||||
assert.match(src, /getFilteredChords\(\)\s*\{\s*if \(hwState\._xfChords !== null\) return hwState\._xfChords;/);
|
||||
assert.match(src, /getChordTemplates\(\)\s*\{\s*return hwState\._xfChordTemplates !== null/);
|
||||
assert.match(src, /getStringCount\(\)\s*\{\s*return hwState\._xfStringCount !== null/);
|
||||
assert.match(src, /getTuning\(\)\s*\{\s*return hwState\._xfTuning !== null/);
|
||||
assert.match(src, /getCapo\(\)\s*\{\s*return hwState\._xfCapo !== null/);
|
||||
assert.match(src, /getCentOffset\(\)\s*\{\s*return hwState\._xfCentOffset !== null/);
|
||||
assert.match(src, /getSongInfo\(\)\s*\{\s*return hwState\.songInfo;\s*\}/,
|
||||
'getSongInfo keeps the original chart metadata contract');
|
||||
});
|
||||
|
||||
test('anchor zoom helpers read the staged anchors first', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const anchorSites = src.match(/hwState\._xfAnchors !== null \? hwState\._xfAnchors\s*\n?\s*: hwState\._filteredAnchors !== null/g) || [];
|
||||
assert.ok(anchorSites.length >= 2, 'getAnchorAt and getMaxFretInWindow both staged-aware');
|
||||
});
|
||||
|
||||
test('init and reconnect clear the stage but keep the provider', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const initBody = extractBlock(src, 'init(canvasEl, container)');
|
||||
const reconnectBody = extractBlock(src, 'reconnect(filename, arrangement, drumPart)');
|
||||
assert.match(initBody, /_clearChartTransformStage\(\);/, 'init clears the stage');
|
||||
assert.match(reconnectBody, /_clearChartTransformStage\(\);/, 'reconnect clears the stage');
|
||||
assert.ok(!/init\([\s\S]{0,2000}_xfProvider = null/.test(src.slice(src.indexOf('const api = {'))),
|
||||
'api reset paths never drop the installed provider');
|
||||
});
|
||||
|
||||
test('default 2D draw path prefers the staged views (drawNotes/drawChords/drawSustains)', () => {
|
||||
const src = fs.readFileSync(highwayDrawJs, 'utf8');
|
||||
const noteSites = src.match(/hwState\._xfNotes !== null \? hwState\._xfNotes/g) || [];
|
||||
assert.ok(noteSites.length >= 2, 'drawNotes and drawSustains staged-aware');
|
||||
assert.match(src, /hwState\._xfChords !== null \? hwState\._xfChords/, 'drawChords staged-aware');
|
||||
});
|
||||
|
||||
test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||
assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/,
|
||||
'label derivation reads a well-formed bundle.tuning first, songInfo otherwise');
|
||||
assert.match(src, /let cap = bundle\.capo;/,
|
||||
'label derivation reads bundle.capo first');
|
||||
// Both cache paths must key on the same bundle-first capo the labels
|
||||
// use (songInfo stays as the fallback branch of each ternary), and all
|
||||
// three sites share the same final fallback (0) so cache signatures
|
||||
// match rendered output.
|
||||
assert.match(src, /const capo =\s*\n\s*bundle && Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
|
||||
'label signature keys on bundle.capo first with a 0 fallback');
|
||||
assert.match(src, /const capo =\s*\n\s*Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
|
||||
'cheap-key fast path keys on bundle.capo first with a 0 fallback');
|
||||
});
|
||||
|
||||
test('chord template reads route through the effective-templates helper', () => {
|
||||
const src = fs.readFileSync(highwayDrawJs, 'utf8');
|
||||
assert.match(src, /export function _effChordTemplates\(hwState\)/);
|
||||
assert.ok(!/getChordTemplateInfo\([^)]*,\s*hwState\.chordTemplates\)/.test(src),
|
||||
'no direct hwState.chordTemplates read remains at template-info call sites');
|
||||
assert.match(src, /_chordRenderCacheTemplates !== effTemplates/, 'render cache keys on effective templates');
|
||||
});
|
||||
@@ -51,10 +51,8 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
);
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'effTemplates'),
|
||||
'cache must key on the effective chordTemplates (detected via !== for change-flag)');
|
||||
assert.match(src, /_effChordTemplates\(hwState\)\s*\{\s*\n?\s*return hwState\._xfChordTemplates !== null \? hwState\._xfChordTemplates : hwState\.chordTemplates;/,
|
||||
'effective templates must derive from hwState.chordTemplates');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
});
|
||||
|
||||
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
|
||||
|
||||
@@ -77,89 +77,3 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
||||
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
||||
});
|
||||
|
||||
// ── The throttle must not starve a renderer that animates on its own clock ──
|
||||
//
|
||||
// The throttle assumes a paused chart is a still picture, so re-rendering it is
|
||||
// waste. That stopped being true when the venue landed: the 3D highway draws the
|
||||
// venue's VIDEO backdrop and its reactive crowd into the same canvas as the
|
||||
// notes, so capping paused frames capped the whole room — pausing the song
|
||||
// dropped the venue to ~10 fps ("everything around the highway drops fps").
|
||||
//
|
||||
// Renderers now opt out via an optional needsContinuousFrames(). Absent or
|
||||
// throwing must mean false, so every other renderer keeps the throttle.
|
||||
|
||||
test('paused throttle defers to a renderer that needs continuous frames', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /_rendererNeedsContinuousFrames\s*\(\s*\)/,
|
||||
'the paused throttle must consult the renderer capability');
|
||||
// The capability must GATE the early-return, not merely be called near it:
|
||||
// the throttle only applies when the renderer does NOT need every frame.
|
||||
assert.match(
|
||||
fn,
|
||||
/!\s*_rendererNeedsContinuousFrames\s*\(\s*\)[\s\S]{0,160}_PAUSED_FRAME_INTERVAL_MS[\s\S]{0,40}return;/,
|
||||
'throttle must be skipped when the renderer needs continuous frames',
|
||||
);
|
||||
});
|
||||
|
||||
test('the capability probe fails closed (absent / non-function / throwing)', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _rendererNeedsContinuousFrames()');
|
||||
assert.match(fn, /typeof\s+r\.needsContinuousFrames\s*!==\s*'function'[\s\S]{0,40}return false/,
|
||||
'a renderer without the method must keep the throttle');
|
||||
assert.match(fn, /catch[\s\S]{0,40}return false/,
|
||||
'a throwing renderer must keep the throttle, not crash the draw loop');
|
||||
assert.match(fn, /===\s*true/,
|
||||
'only an explicit true opts out — a truthy accident must not disable the throttle');
|
||||
});
|
||||
|
||||
test('3D highway claims continuous frames for BOTH sources of venue motion', () => {
|
||||
const h3d = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||
const fn = extractBlock(h3d, 'needsContinuousFrames()');
|
||||
// (1) a crowd video rolling on its own clock (career venue pack)
|
||||
assert.match(fn, /_venueCrowdVideos/, 'must key off the actual crowd video elements');
|
||||
assert.match(fn, /\.paused/, 'a paused video is a still frame');
|
||||
// (2) the venue scene's OWN fake-depth motion — backdrop breathe, haze drift,
|
||||
// warmth pulse, shimmer. Math.sin(t) in the draw loop, so it only moves while
|
||||
// we get frames, and it runs with NO pack at all. Missing this meant the venue
|
||||
// still stuttered on pause / count-in / credits whenever no video was rolling.
|
||||
assert.match(fn, /_venueEffectiveMotionMode\s*\(\s*\)\s*!==\s*'off'/,
|
||||
'the venue scene animates without any video — it must claim frames too');
|
||||
// ...and with no venue at all the paused scene IS static: the #654 GPU saving
|
||||
// must survive, so the method has to be able to return false.
|
||||
assert.match(fn, /return false;/, 'must fall through to false on a plain 3D highway');
|
||||
});
|
||||
|
||||
// ── a SUPERSEDED init is not a FAILED init ──────────────────────────────────
|
||||
//
|
||||
// Starting a gig dropped the player onto the fallback 2D highway with no venue.
|
||||
//
|
||||
// setViz('venue') installs the 3D renderer, whose init is async; the gig then
|
||||
// immediately starts its play queue, and playSong() re-initialises that same
|
||||
// renderer a tick later. A renderer mints a fresh readyPromise per init() and
|
||||
// rejects the previous one with "superseded" — but highway.js only checked that
|
||||
// the RENDERER object was unchanged, which it is. So it treated a healthy
|
||||
// re-initialising renderer as a failed one, tore it down, and reverted to 2D:
|
||||
//
|
||||
// renderer async init failure: Error: superseded
|
||||
// viz picker: reverted to default renderer (async-init-failure)
|
||||
//
|
||||
// Reproduced and fixed against the real build (venue stays selected, scene
|
||||
// active, no viz:reverted).
|
||||
|
||||
test('a superseded readyPromise must not revert the viz to 2D', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _handleAsyncInitFailure(e)');
|
||||
assert.match(fn, /readyPromise\s*!==\s*rp[\s\S]{0,40}return/,
|
||||
'a rejection from a STALE readyPromise (the renderer has since re-init\'d) must be ' +
|
||||
'ignored — otherwise a re-initialising renderer is torn down as if it had failed');
|
||||
// The renderer-identity check must survive too: a rejection belonging to a
|
||||
// renderer that has since been REPLACED is also not our problem.
|
||||
assert.match(fn, /hwState\._renderer\s*!==\s*_installedRenderer[\s\S]{0,20}return/,
|
||||
'the renderer-identity guard must remain');
|
||||
// ...and a genuine failure of the CURRENT init cycle must still revert.
|
||||
assert.match(fn, /_emitVizReverted\s*\(\s*'async-init-failure'\s*\)/,
|
||||
'a real async-init failure must still fall back to the default renderer');
|
||||
});
|
||||
|
||||
@@ -49,80 +49,3 @@ test('peekNext is null after clear', () => {
|
||||
q.clear();
|
||||
assert.strictEqual(q.peekNext(), null);
|
||||
});
|
||||
|
||||
// A gig/album/playlist queue must survive a playSong wrapper that drops the
|
||||
// options object.
|
||||
//
|
||||
// The queue tells playSong "don't clear the queue I'm driving" via
|
||||
// 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 3rd arg. With just the in-band
|
||||
// flag, playSong cleared the queue the instant its first song started, so a gig
|
||||
// never advanced (feedBack#… tester: "Passports does not advance in the song
|
||||
// queue"). The queue now also raises an out-of-band flag, _consumeInternalPlay(),
|
||||
// which playSong honours regardless of the wrapper chain.
|
||||
|
||||
// The real clear-guard from session.js, driven against the queue.
|
||||
function clearGuard(win, options) {
|
||||
const pq = win.feedBack && win.feedBack.playQueue;
|
||||
const queueDriven = (options && options.fromQueue)
|
||||
|| (pq && typeof pq._consumeInternalPlay === 'function' && pq._consumeInternalPlay());
|
||||
if (!queueDriven && pq) pq.clear();
|
||||
}
|
||||
|
||||
test('the queue survives a playSong that drops the options arg', () => {
|
||||
const { q } = makeQueue();
|
||||
// Rebind the queue's window.playSong to a wrapper that forwards ONLY
|
||||
// (filename, arrangement) — exactly the plugin bug — and runs the real guard.
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// Reach the same window the IIFE closed over: re-drive through the guard by
|
||||
// calling start and simulating what _play's playSong does.
|
||||
// We can't rebind the closed-over window, so instead assert the out-of-band
|
||||
// signal directly: _play sets it, and the guard consumes it.
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
// After start()->_play, the internal flag was set; the guard (which the real
|
||||
// playSong runs) must see it as queue-driven and NOT clear.
|
||||
win.feedBack.playQueue = q;
|
||||
clearGuard(win, undefined /* wrapper dropped options */);
|
||||
assert.strictEqual(q.active(), true, 'a dropped options arg must not clear the queue');
|
||||
assert.strictEqual(q.remaining(), 2, 'the queue must still have its remaining tracks');
|
||||
});
|
||||
|
||||
test('_consumeInternalPlay is one-shot — a later MANUAL play still clears', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'album' });
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// First guard call (the queue's own play) consumes the flag → no clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), true);
|
||||
// A subsequent MANUAL play (no fromQueue, flag already consumed) must clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), false, 'a manual play after the queue play must abandon the queue');
|
||||
});
|
||||
|
||||
test('fromQueue in options still works on its own (in-band path)', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'gig' });
|
||||
// consume the internal flag first so ONLY options.fromQueue is under test
|
||||
q._consumeInternalPlay();
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
clearGuard(win, { fromQueue: true });
|
||||
assert.strictEqual(q.active(), true, 'options.fromQueue alone must still keep the queue');
|
||||
});
|
||||
|
||||
// isContinuation(): true for song 2..N of a set, false for the first song / a
|
||||
// standalone play. The venue uses it to fly in once on arrival, then carry the
|
||||
// room between songs instead of replaying the arrival flyover every track
|
||||
// (tester: "it showed the flyover intro again" on a gig's second song).
|
||||
test('isContinuation is false on the first song, true after advancing', () => {
|
||||
const { q } = makeQueue();
|
||||
assert.strictEqual(q.isContinuation(), false, 'idle queue is not a continuation');
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
assert.strictEqual(q.isContinuation(), false, 'the FIRST song of a set is an arrival, not a continuation');
|
||||
q.advance();
|
||||
assert.strictEqual(q.isContinuation(), true, 'song 2 is a continuation — no re-flyover');
|
||||
q.advance();
|
||||
assert.strictEqual(q.isContinuation(), true, 'song 3 too');
|
||||
q.clear();
|
||||
assert.strictEqual(q.isContinuation(), false, 'a cleared queue is not a continuation');
|
||||
});
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
// The playlist tuning check (static/v3/playlists.js).
|
||||
//
|
||||
// A bass-playing tester built playlists grouped BY TUNING so a practice run
|
||||
// needs no retune, using a library filter that only ever looked at the guitar
|
||||
// tuning. Those playlists still hold songs he can't play without stopping. The
|
||||
// check flags them; it must never quietly edit the playlist, and — the part
|
||||
// that decides whether he trusts it — it must not call a song "wrong tuning"
|
||||
// when it simply couldn't work the song out.
|
||||
//
|
||||
// The real functions are lifted out of playlists.js and run in a vm (the module
|
||||
// is a browser IIFE with no export surface, and there is no jsdom here). No
|
||||
// re-implementation: if the source changes, these tests run the changed code.
|
||||
|
||||
'use strict';
|
||||
|
||||
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 PL_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js');
|
||||
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const TUNER_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
|
||||
const PL_SRC = fs.readFileSync(PL_JS, 'utf8');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
if (start === -1) throw new Error(`extractBlock: '${startMarker}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractBlock: unbalanced braces after '${startMarker}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
// The REAL offset parser the checker calls through window.parseRawTuningOffsets.
|
||||
function loadParseRawTuningOffsets() {
|
||||
const body = fs.readFileSync(TUNING_JS, 'utf8').replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(body + '\nexports.parseRawTuningOffsets = parseRawTuningOffsets;', sandbox);
|
||||
return sandbox.exports.parseRawTuningOffsets;
|
||||
}
|
||||
|
||||
// Build a sandbox holding the real checker functions, over a caller-supplied
|
||||
// window (so each test controls the host capabilities and the coverage stub
|
||||
// boundary). `coverage` stands in for the tuner plugin's coverageReport — a
|
||||
// genuinely external collaborator, not the subject under test; the contract
|
||||
// test at the bottom pins its report shape so these fixtures can't drift.
|
||||
function loadChecker(opts) {
|
||||
opts = opts || {};
|
||||
const calls = [];
|
||||
const window = {
|
||||
parseRawTuningOffsets: loadParseRawTuningOffsets(),
|
||||
feedBack: opts.noWorkingTuning ? {} : { workingTuning: { get: () => ({ instrument: opts.instrument || 'bass' }) } },
|
||||
_tunerAutoOpen: opts.noCoverage ? undefined : {
|
||||
coverageReport: async (info) => {
|
||||
calls.push(info);
|
||||
if (opts.coverage) return opts.coverage(info);
|
||||
throw new Error('no coverage fixture supplied');
|
||||
},
|
||||
},
|
||||
};
|
||||
const sandbox = { window, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
extractBlock(PL_SRC, 'function rowTuningForCheck(') + '\n'
|
||||
+ extractBlock(PL_SRC, 'function tuningStateFromReport(') + '\n'
|
||||
+ extractBlock(PL_SRC, 'async function checkPlaylistTuning(') + '\n'
|
||||
+ extractBlock(PL_SRC, 'function tuningSummaryHtml(') + '\n'
|
||||
+ 'exports.rowTuningForCheck = rowTuningForCheck;\n'
|
||||
+ 'exports.tuningStateFromReport = tuningStateFromReport;\n'
|
||||
+ 'exports.checkPlaylistTuning = checkPlaylistTuning;\n'
|
||||
+ 'exports.tuningSummaryHtml = tuningSummaryHtml;\n',
|
||||
sandbox
|
||||
);
|
||||
return { ...sandbox.exports, calls };
|
||||
}
|
||||
|
||||
// Report shapes exactly as plugins/tuner/screen.js documents and returns them.
|
||||
const REPORT_COVERED = { covered: true, retune: [], reference: false, cantCover: false };
|
||||
const REPORT_RETUNE = { covered: false, retune: [{ from: 'E', to: 'D' }], reference: false, cantCover: false };
|
||||
const REPORT_REFERENCE = { covered: false, retune: [], reference: true, cantCover: false };
|
||||
const REPORT_CANT_COVER = { covered: false, retune: [], reference: false, cantCover: true };
|
||||
// The "I couldn't work it out" report — the tuner's `none` bail-out. Byte-for-byte
|
||||
// a not-covered report with no reason attached.
|
||||
const REPORT_UNKNOWN = { covered: false, retune: [], reference: false, cantCover: false };
|
||||
|
||||
// The checker runs inside the vm, so the arrays it returns belong to another
|
||||
// realm and would fail deepStrictEqual's prototype check. Copy into host arrays.
|
||||
const plain = (a) => Array.from(a);
|
||||
|
||||
const song = (over) => Object.assign(
|
||||
{ filename: 'a.sloppak', title: 'A', tuning_name: 'E Standard', tuning_offsets: '0 0 0 0 0 0', bass_only: false },
|
||||
over
|
||||
);
|
||||
|
||||
// ── The unknown-vs-mismatch distinction ─────────────────────────────────────
|
||||
|
||||
test('a covered report is a match', () => {
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(REPORT_COVERED), 'match');
|
||||
});
|
||||
|
||||
test('a not-covered report WITH a reason is a mismatch', () => {
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(REPORT_RETUNE), 'mismatch');
|
||||
assert.equal(tuningStateFromReport(REPORT_REFERENCE), 'mismatch');
|
||||
assert.equal(tuningStateFromReport(REPORT_CANT_COVER), 'mismatch');
|
||||
});
|
||||
|
||||
test('a not-covered report with NO reason is unknown, not a mismatch', () => {
|
||||
// This is the whole trust argument. The tuner returns this identical shape
|
||||
// when settings/tuner data are missing. Treating it as "wrong tuning" (which
|
||||
// the library grid's chip decorator does) would put a false ⚠ on songs that
|
||||
// are perfectly playable, on a playlist the user curated by hand.
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(REPORT_UNKNOWN), 'unknown');
|
||||
});
|
||||
|
||||
test('a null/absent report is unknown', () => {
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(null), 'unknown');
|
||||
assert.equal(tuningStateFromReport(undefined), 'unknown');
|
||||
});
|
||||
|
||||
// ── Round-tripping a whole playlist ─────────────────────────────────────────
|
||||
|
||||
test('each song is scored and reported in playlist order', async () => {
|
||||
const byFile = {
|
||||
'match.sloppak': REPORT_COVERED,
|
||||
'bad.sloppak': REPORT_RETUNE,
|
||||
'huh.sloppak': REPORT_UNKNOWN,
|
||||
};
|
||||
const songs = [
|
||||
song({ filename: 'match.sloppak', title: 'Match' }),
|
||||
song({ filename: 'bad.sloppak', title: 'Bad', tuning_offsets: '-2 -2 -2 -2 -2 -2' }),
|
||||
song({ filename: 'huh.sloppak', title: 'Huh', tuning_offsets: '-1 0 0 0 0 0' }),
|
||||
];
|
||||
// Resolve the fixture from the offsets the checker actually passed, so the
|
||||
// mapping can't silently drift out of playlist order.
|
||||
const byOffsets = new Map(songs.map((s) => [s.tuning_offsets.replace(/\s+/g, ','), byFile[s.filename]]));
|
||||
const checker = loadChecker({ coverage: async (info) => byOffsets.get(info.tuning.join(',')) });
|
||||
const out = await checker.checkPlaylistTuning(songs);
|
||||
assert.deepEqual(plain(out.map((r) => r.state)), ['match', 'mismatch', 'unknown']);
|
||||
assert.deepEqual(plain(out.map((r) => r.song.filename)), songs.map((s) => s.filename));
|
||||
});
|
||||
|
||||
test('a song with no usable tuning data is unknown WITHOUT consulting coverage', async () => {
|
||||
// Adversarial payloads: empty, whitespace, a non-numeric name with no
|
||||
// offsets, and a garbage offsets string. None of these can be scored, and
|
||||
// asking coverage about them would invite a bogus not-covered → false ⚠.
|
||||
const checker = loadChecker({ coverage: async () => REPORT_RETUNE });
|
||||
const out = await checker.checkPlaylistTuning([
|
||||
song({ filename: 'a', tuning_offsets: '', tuning_name: '' }),
|
||||
song({ filename: 'b', tuning_offsets: ' ', tuning_name: ' ' }),
|
||||
song({ filename: 'c', tuning_offsets: '', tuning_name: 'E Standard' }),
|
||||
song({ filename: 'd', tuning_offsets: 'not offsets', tuning_name: 'x' }),
|
||||
song({ filename: 'e', tuning_offsets: null, tuning_name: null }),
|
||||
]);
|
||||
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown', 'unknown', 'unknown', 'unknown', 'unknown']);
|
||||
assert.equal(checker.calls.length, 0, 'coverage must not be asked about unscoreable rows');
|
||||
});
|
||||
|
||||
test('a coverage call that throws degrades to unknown, not mismatch', async () => {
|
||||
const checker = loadChecker({ coverage: async () => { throw new Error('tuner exploded'); } });
|
||||
const out = await checker.checkPlaylistTuning([song({})]);
|
||||
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown']);
|
||||
});
|
||||
|
||||
test('the bass perspective uses #1003 bass offsets instead of guitar offsets', async () => {
|
||||
const checker = loadChecker({ instrument: 'bass', coverage: async () => REPORT_COVERED });
|
||||
await checker.checkPlaylistTuning([song({
|
||||
tuning_offsets: '0 0 0 0 0 0',
|
||||
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
|
||||
})]);
|
||||
assert.deepEqual(plain(checker.calls[0].tuning), [-2, -2, -2, -2, -2, -2]);
|
||||
assert.equal(checker.calls[0].arrangement, 'Bass');
|
||||
});
|
||||
|
||||
test("the guitar perspective ignores a song's bass offsets", async () => {
|
||||
const checker = loadChecker({ instrument: 'guitar', coverage: async () => REPORT_COVERED });
|
||||
await checker.checkPlaylistTuning([song({
|
||||
tuning_offsets: '0 0 0 0 0 0',
|
||||
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
|
||||
})]);
|
||||
assert.deepEqual(plain(checker.calls[0].tuning), [0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(checker.calls[0].arrangement, 'Lead');
|
||||
});
|
||||
|
||||
test('a bass-only chart is scored against bass base pitches', async () => {
|
||||
// Otherwise a 4-string bass tuning read as guitar can false-match — the
|
||||
// cross-instrument confusion this whole feature exists to undo.
|
||||
const checker = loadChecker({ coverage: async () => REPORT_COVERED });
|
||||
await checker.checkPlaylistTuning([
|
||||
song({ filename: 'bass', tuning_offsets: '0 0 0 0', bass_only: true }),
|
||||
song({ filename: 'gtr', tuning_offsets: '0 0 0 0 0 0', bass_only: false }),
|
||||
]);
|
||||
assert.deepEqual(checker.calls.map((c) => c.arrangement), ['Bass', 'Lead']);
|
||||
assert.deepEqual(checker.calls.map((c) => c.stringCount), [4, 6]);
|
||||
});
|
||||
|
||||
test('the check stays silent when the host exposes no tuning perspective', async () => {
|
||||
// No working-tuning capability, or no tuner coverage → null, and the caller
|
||||
// renders the playlist exactly as before. Guessing "guitar" here would
|
||||
// reproduce the original bug in a new place.
|
||||
for (const opts of [{ noWorkingTuning: true }, { noCoverage: true }]) {
|
||||
const checker = loadChecker(Object.assign({ coverage: async () => REPORT_COVERED }, opts));
|
||||
assert.equal(await checker.checkPlaylistTuning([song({})]), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty playlist yields an empty result, not a crash', async () => {
|
||||
const checker = loadChecker({ coverage: async () => REPORT_COVERED });
|
||||
assert.deepEqual(plain(await checker.checkPlaylistTuning([])), []);
|
||||
assert.deepEqual(plain(await checker.checkPlaylistTuning(null)), []);
|
||||
});
|
||||
|
||||
// ── The summary ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the summary counts mismatches against the playlist total', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const results = [
|
||||
{ state: 'mismatch' }, { state: 'mismatch' }, { state: 'mismatch' },
|
||||
...Array(21).fill({ state: 'match' }),
|
||||
];
|
||||
const html = tuningSummaryHtml(results);
|
||||
assert.match(html, /<strong>3<\/strong> of 24 songs aren't in your tuning/);
|
||||
});
|
||||
|
||||
test('unknowns are reported separately from mismatches and never counted as them', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const html = tuningSummaryHtml([{ state: 'mismatch' }, { state: 'unknown' }, { state: 'match' }]);
|
||||
assert.match(html, /<strong>1<\/strong> of 3 songs aren't in your tuning/);
|
||||
assert.match(html, /1 couldn't be checked/);
|
||||
assert.match(html, /left alone/);
|
||||
});
|
||||
|
||||
test('an all-unknown playlist makes no mismatch claim and offers no removal', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const html = tuningSummaryHtml([{ state: 'unknown' }, { state: 'unknown' }]);
|
||||
assert.doesNotMatch(html, /aren't in your tuning/);
|
||||
assert.doesNotMatch(html, /v3-pl-tune-remove/);
|
||||
assert.match(html, /2 couldn't be checked/);
|
||||
});
|
||||
|
||||
test('a clean playlist offers no filter and no removal button', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const html = tuningSummaryHtml([{ state: 'match' }, { state: 'match' }]);
|
||||
assert.match(html, /All 2 songs are in your tuning/);
|
||||
assert.doesNotMatch(html, /v3-pl-tune-only/);
|
||||
assert.doesNotMatch(html, /v3-pl-tune-remove/);
|
||||
});
|
||||
|
||||
test('an empty playlist renders no summary at all', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
assert.equal(tuningSummaryHtml([]), '');
|
||||
});
|
||||
|
||||
// ── Read-only / explicit-action guarantees (source-level) ───────────────────
|
||||
|
||||
test('the check itself never mutates the playlist', () => {
|
||||
// checkPlaylistTuning and its helpers must contain no write verbs. The only
|
||||
// DELETE in the module's tuning path is inside the confirmed removal.
|
||||
const fns = ['function rowTuningForCheck(', 'function tuningStateFromReport(',
|
||||
'async function checkPlaylistTuning(', 'function tuningSummaryHtml('];
|
||||
for (const marker of fns) {
|
||||
const body = extractBlock(PL_SRC, marker);
|
||||
assert.doesNotMatch(body, /DELETE|jsend\(|method:/,
|
||||
marker + ' must not mutate the playlist');
|
||||
}
|
||||
});
|
||||
|
||||
test('bulk removal names every song and is confirmed before any DELETE', () => {
|
||||
const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
|
||||
// The confirm is built from the doomed titles, each escaped. The row markup
|
||||
// moved from <li> to a bulleted <div> so the confirm needs no Tailwind class
|
||||
// the committed CSS lacks — what matters is that every song is named and
|
||||
// escaped, not which element wraps it.
|
||||
assert.match(body, /doomed\.map\(\(s\) => '<(?:li|div)>[^']*' \+ esc\(s\.title \|\| s\.filename\)/);
|
||||
// … it is awaited, and an early return happens before the delete loop.
|
||||
const confirmAt = body.indexOf('uiConfirm');
|
||||
const bailAt = body.indexOf('if (!ok) return;');
|
||||
const deleteAt = body.indexOf("method: 'DELETE'");
|
||||
assert.ok(confirmAt > -1 && bailAt > confirmAt && deleteAt > bailAt,
|
||||
'DELETE must come after an awaited confirm and its bail-out');
|
||||
// And it says the songs survive in the library — the "reversible-feeling" ask.
|
||||
assert.match(body, /stay in your library/);
|
||||
});
|
||||
|
||||
test('removal targets only mismatches — never unknowns', () => {
|
||||
const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
|
||||
assert.match(body, /results\.filter\(\(r\) => r\.state === 'mismatch'\)\.map\(\(r\) => r\.song\)/);
|
||||
assert.doesNotMatch(body, /doomed[\s\S]{0,200}'unknown'/);
|
||||
});
|
||||
|
||||
test('unknown is styled distinctly from mismatch', () => {
|
||||
const body = extractBlock(PL_SRC, 'function paintTuningChip(');
|
||||
// Mismatch is amber; unknown is the neutral chip, dimmed — not amber.
|
||||
assert.match(body, /state === 'mismatch' \? 'bg-amber-400'/);
|
||||
assert.match(body, /state === 'unknown'\) chip\.classList\.add\('opacity-60'\)/);
|
||||
// …and both carry a text marker, so the states never rest on colour alone.
|
||||
assert.match(body, /state === 'mismatch' \? ' ⚠' : state === 'unknown' \? ' \?'/);
|
||||
});
|
||||
|
||||
// ── Collaborator contract ───────────────────────────────────────────────────
|
||||
|
||||
test('the tuner coverage report still carries the fields the states are read from', () => {
|
||||
// If the tuner plugin drops `retune`/`reference`/`cantCover`, every mismatch
|
||||
// silently degrades to "unknown" and the feature goes quiet. Pin the shape
|
||||
// the fixtures above rely on.
|
||||
const tuner = fs.readFileSync(TUNER_JS, 'utf8');
|
||||
const body = extractBlock(tuner, 'async function _computeCoverageReport(');
|
||||
for (const field of ['covered', 'retune', 'reference', 'cantCover']) {
|
||||
assert.match(body, new RegExp(field), `coverage report must still carry ${field}`);
|
||||
}
|
||||
assert.match(body, /const none = \{ covered: false, retune: \[\], reference: false, cantCover: false \}/,
|
||||
'the no-data bail-out must stay a reasonless not-covered report — that is what "unknown" detects');
|
||||
});
|
||||
@@ -67,27 +67,11 @@ test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
// The card renderer's row variable was renamed song → shown when grouped
|
||||
// cards landed (the badge reads the representative chart); accept either.
|
||||
// The raw read then moved behind shownTuningName() so the badge can answer
|
||||
// for the active tuning perspective — accept that indirection too, and pin
|
||||
// the fallback inside the helper below so this stays a real guard.
|
||||
assert.match(
|
||||
src,
|
||||
/displayTuningName\((?:(?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning|shownTuning)\)/,
|
||||
);
|
||||
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
test('the tuning-perspective helper still falls back to tuning_name || tuning', () => {
|
||||
// shownTuningName() is what the badge now reads. With no perspective field
|
||||
// set (guitar-lead, the default) it must resolve exactly what the badge
|
||||
// used to read inline, or guitar players silently lose their tuning label.
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
const body = src.match(/function shownTuningName\(song\)\s*\{[\s\S]*?\n {4}\}/);
|
||||
assert.ok(body, 'shownTuningName() not found — the badge read moved again');
|
||||
assert.match(body[0], /return song\.tuning_name \|\| song\.tuning;/);
|
||||
});
|
||||
|
||||
test('raw offset tuning_name does not appear in rendered card HTML', () => {
|
||||
const html = renderSongCardBadge({ tuning_name: '-2 0 0 0 -2' }, helpers);
|
||||
assert.doesNotMatch(html, /-2 0 0 0 -2/);
|
||||
|
||||
@@ -208,7 +208,7 @@ test('index.html loads venue deps before venue-scene-3d', () => {
|
||||
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||
});
|
||||
|
||||
test('syncViz activates only for venue visualization id, and only on the player', () => {
|
||||
test('syncViz activates only for venue visualization id', () => {
|
||||
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
||||
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||
@@ -216,14 +216,7 @@ test('syncViz activates only for venue visualization id, and only on the player'
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.feedBack = { on() {} };
|
||||
// The venue is scoped to the song player: selecting Venue is a preference
|
||||
// for THAT screen, not a licence to paint the venue over anything else that
|
||||
// borrows the highway_3d renderer (Virtuoso's practice charts did exactly
|
||||
// that). syncViz therefore needs to know which screen is showing.
|
||||
const onScreen = (id) => { global.document = { querySelector: (s) => (s === '.screen.active' && id ? { id } : null) }; };
|
||||
const prevDoc = global.document;
|
||||
try {
|
||||
onScreen('player');
|
||||
venueScene.deactivate();
|
||||
venueScene.syncViz('highway_3d');
|
||||
assert.equal(global._h3dActive, false);
|
||||
@@ -231,16 +224,7 @@ test('syncViz activates only for venue visualization id, and only on the player'
|
||||
assert.equal(global._h3dActive, true);
|
||||
assert.equal(venueScene.getState().active, true);
|
||||
assert.equal(venueScene.getState().themeId, 'small-club');
|
||||
|
||||
// ...and the same call OFF the player must not activate it.
|
||||
venueScene.deactivate();
|
||||
onScreen('virtuoso');
|
||||
venueScene.syncViz('venue');
|
||||
assert.equal(global._h3dActive, false,
|
||||
'Venue selected must NOT paint the venue onto the Virtuoso highway');
|
||||
assert.equal(venueScene.getState().active, false);
|
||||
} finally {
|
||||
global.document = prevDoc;
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
// Two venue bugs reported from a live career session.
|
||||
//
|
||||
// 1. Changing arrangement mid-song replayed the venue arrival flyover. The
|
||||
// camera flew in from the back of the room again, every time the player
|
||||
// switched lead -> rhythm. changeArrangement() reloads the song through the
|
||||
// normal load path, so highway.js re-emits `song:loaded` — same filename,
|
||||
// new arrangement — and the venue could not tell that from a fresh arrival.
|
||||
// The player is already on stage; the room should just carry on.
|
||||
//
|
||||
// 2. With Venue selected, the venue backdrop showed up on the VIRTUOSO highway.
|
||||
// The venue was gated purely on the viz selection, which is a global
|
||||
// preference and says nothing about what is on screen. Virtuoso borrows the
|
||||
// same highway_3d renderer for its practice charts, so it inherited the
|
||||
// crowd and the stage behind a chromatic exercise. The venue belongs to the
|
||||
// song player and nowhere else.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const crowd = require('../../static/v3/venue-crowd.js');
|
||||
|
||||
// ── 1. arrangement switch is not an arrival ────────────────────────────────
|
||||
|
||||
test('same filename = arrangement switch (no arrival flyover)', () => {
|
||||
// changeArrangement() re-emits song:loaded for the song already on stage.
|
||||
assert.equal(crowd.isArrangementSwitch('song.feedpak', 'song.feedpak'), true);
|
||||
});
|
||||
|
||||
test('different filename = a genuinely new song (flyover is correct)', () => {
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', 'b.feedpak'), false);
|
||||
});
|
||||
|
||||
test('first load of the session is an arrival, not a switch', () => {
|
||||
// No previous song -> the flyover must play.
|
||||
assert.equal(crowd.isArrangementSwitch('', 'a.feedpak'), false);
|
||||
});
|
||||
|
||||
test('a missing filename is never treated as a switch', () => {
|
||||
// Otherwise a malformed payload would silently suppress the flyover for the
|
||||
// rest of the session.
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', ''), false);
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', undefined), false);
|
||||
assert.equal(crowd.isArrangementSwitch('', ''), false);
|
||||
});
|
||||
|
||||
// ── 2. the venue belongs to the player screen ──────────────────────────────
|
||||
|
||||
const scene = require('../../static/v3/venue-scene-3d.js');
|
||||
|
||||
// Venue MUST be the selected visualization for these to mean anything: if the
|
||||
// viz were unset, shouldBeActive() would be false for the wrong reason and the
|
||||
// virtuoso assertion below would pass vacuously. Force the viz on, so the only
|
||||
// thing under test is the SCREEN gate.
|
||||
function withScreen(id, fn) {
|
||||
const prevDoc = global.document;
|
||||
const prevViz = global.v3VenueViz;
|
||||
global.v3VenueViz = {
|
||||
isVenueVisualization: (v) => String(v) === 'venue',
|
||||
getSelectedVizId: () => 'venue',
|
||||
};
|
||||
global.document = {
|
||||
querySelector(sel) {
|
||||
if (sel !== '.screen.active') return null;
|
||||
return id ? { id } : null;
|
||||
},
|
||||
};
|
||||
try { return fn(); } finally { global.document = prevDoc; global.v3VenueViz = prevViz; }
|
||||
}
|
||||
|
||||
test('guard: with Venue selected AND on the player, the venue IS active', () => {
|
||||
// If this ever fails, every "not active" test below is vacuous.
|
||||
withScreen('player', () => {
|
||||
assert.equal(scene.shouldBeActive(), true,
|
||||
'the screen gate must not break the normal case');
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is active on the player screen', () => {
|
||||
withScreen('player', () => {
|
||||
assert.equal(scene.isPlayerScreen(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is NOT active on the virtuoso screen (the bug)', () => {
|
||||
withScreen('virtuoso', () => {
|
||||
assert.equal(scene.isPlayerScreen(), false,
|
||||
'Virtuoso borrows the same highway_3d renderer — the venue backdrop ' +
|
||||
'must not follow it there');
|
||||
assert.equal(scene.shouldBeActive(), false,
|
||||
'selecting Venue is a preference for the PLAYER; it is not a licence ' +
|
||||
'to paint the venue over whatever else is using the renderer');
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is not active on any other screen either', () => {
|
||||
for (const id of ['v3-home', 'plugin-folder_library', 'settings', 'career']) {
|
||||
withScreen(id, () => {
|
||||
assert.equal(scene.shouldBeActive(), false, `venue must not be active on ${id}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('no active screen at all is not the player', () => {
|
||||
withScreen(null, () => {
|
||||
assert.equal(scene.isPlayerScreen(), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('a throwing document does not take the venue down with it', () => {
|
||||
const prev = global.document;
|
||||
global.document = { querySelector() { throw new Error('detached'); } };
|
||||
try {
|
||||
assert.equal(scene.isPlayerScreen(), false, 'must fail closed, not throw');
|
||||
} finally {
|
||||
global.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// The arrival flyover must NOT replay for songs 2..N of a set. onSongLoaded
|
||||
// consults the play queue: a continuation (gig/album/playlist song 2+) carries
|
||||
// the room over with a loop crossfade, only an arrival plays the intro.
|
||||
test('a set continuation carries the room over instead of re-flying-in', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-crowd.js'), 'utf8');
|
||||
const start = src.indexOf('function onSongLoaded(');
|
||||
const open = src.indexOf('{', src.indexOf(')', start));
|
||||
let depth = 1, i = open + 1;
|
||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
|
||||
const fn = src.slice(start, i);
|
||||
const contIdx = fn.search(/_isSetContinuation\s*\(\s*\)/);
|
||||
const introIdx = fn.search(/playIntro\s*\(/);
|
||||
assert.ok(contIdx !== -1, 'onSongLoaded must consult the set-continuation signal');
|
||||
assert.ok(introIdx !== -1, 'the intro must still exist for a real arrival');
|
||||
assert.ok(contIdx < introIdx, 'the continuation check must gate the flyover — a set song 2+ must not fly in');
|
||||
});
|
||||
@@ -445,30 +445,3 @@ def test_gold_intake_rejects_junk(client, meta_db):
|
||||
res = client.post("/api/plugins/career/drill-state",
|
||||
json={"byNode": {}, "goldImprov": blob})
|
||||
assert res.status_code == 413
|
||||
|
||||
|
||||
def test_gig_includes_songs_played_on_another_instrument(client, meta_db):
|
||||
# feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list".
|
||||
# A song played on a DIFFERENT instrument's arrangement has a stats row, so it
|
||||
# was excluded from the unplayed filler — and its played bucket is that other
|
||||
# instrument's, not this passport's — so it fell into a gap and could never be
|
||||
# gigged. A guitar passport with a library of bass-played metalcore got a 404.
|
||||
for i in range(137):
|
||||
meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
|
||||
assert res.status_code == 200, "a full library of the genre must never 404"
|
||||
assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap"
|
||||
|
||||
|
||||
def test_gig_reroll_changes_the_set(client, meta_db):
|
||||
# feedBack#… (tester): "Passport re-roll does not change songs". A set drawn
|
||||
# from the filler used to be the library's first N in table order, every time.
|
||||
for i in range(40):
|
||||
meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore")
|
||||
sets = set()
|
||||
for _ in range(5):
|
||||
r = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
|
||||
sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"])))
|
||||
assert len(sets) > 1, "re-roll must be able to produce a different set"
|
||||
|
||||
@@ -83,25 +83,10 @@ def test_download_without_published_pack_404s(client):
|
||||
|
||||
def test_download_locked_venue_403s(client, monkeypatch):
|
||||
club = career_routes._venue("club")
|
||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
|
||||
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||
|
||||
|
||||
def test_placeholder_pack_is_not_offered_until_published(client, monkeypatch):
|
||||
# A committed manifest carries a 0-byte placeholder until its release is
|
||||
# published. Such a pack must not be offered (has_pack False) and its
|
||||
# download must 404 — else the UI shows a button that can only fail.
|
||||
monkeypatch.setattr(career_routes, "_bundled", lambda vid: False)
|
||||
club = career_routes._venue("club")
|
||||
monkeypatch.setitem(club, "pack",
|
||||
{"url": "http://x/c.zip", "sha256": "0" * 64, "bytes": 0})
|
||||
by_id = {v["id"]: v for v in client.get("/api/plugins/career/state").json()["venues"]}
|
||||
assert by_id["club"]["has_pack"] is False # placeholder → not offered
|
||||
assert by_id["arena"]["has_pack"] is True # arena ships real bytes
|
||||
# Even forced, an unpublished pack won't start a download.
|
||||
assert client.post("/api/plugins/career/packs/club/download").status_code == 404
|
||||
|
||||
|
||||
def test_bundled_bar_pack_is_installed_and_served(client):
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
bar = {v["id"]: v for v in state["venues"]}["bar"]
|
||||
@@ -182,227 +167,10 @@ def test_download_worker_end_to_end(client, tmp_path):
|
||||
assert "sha256" in bad["error"]
|
||||
|
||||
|
||||
def test_content_packs_build_roundtrips_through_download(client, tmp_path):
|
||||
# tools/content_packs.py must produce a zip the real career worker accepts:
|
||||
# build_pack → manifest_entry → _download_pack → installed.
|
||||
from tools import content_packs
|
||||
|
||||
src = tmp_path / "bar"
|
||||
src.mkdir()
|
||||
for s in career_routes.REQUIRED_LOOPS:
|
||||
(src / f"{s}.mp4").write_bytes(b"fake-" + s.encode())
|
||||
(src / "cheer.mp4").write_bytes(b"fake-cheer")
|
||||
(src / "manifest.json").write_text(json.dumps({
|
||||
"venue": "bar", "version": 1,
|
||||
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
|
||||
"stingers": {"cheer": "cheer.mp4"},
|
||||
}))
|
||||
out_dir = tmp_path / "packs"
|
||||
zip_path = out_dir / content_packs.pack_asset("bar", 1)
|
||||
info = content_packs.build_pack(src, zip_path)
|
||||
entry = content_packs.manifest_entry(zip_path, zip_path.resolve().as_uri())
|
||||
assert entry["sha256"] == info["sha256"] and entry["bytes"] == info["bytes"]
|
||||
|
||||
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||
career_routes._download_pack("bar", entry, progress)
|
||||
assert progress["status"] == "done", progress["error"]
|
||||
assert career_routes._installed("bar")
|
||||
|
||||
|
||||
def test_content_packs_rejects_files_the_downloader_would_refuse(tmp_path):
|
||||
# A stray file (e.g. macOS .DS_Store) must fail the build, not get published
|
||||
# and then break every client's download at _validate_pack_dir.
|
||||
from tools import content_packs
|
||||
|
||||
src = tmp_path / "bar"
|
||||
src.mkdir()
|
||||
(src / "bored.mp4").write_bytes(b"fake")
|
||||
(src / ".DS_Store").write_bytes(b"junk")
|
||||
try:
|
||||
content_packs.build_pack(src, tmp_path / "bar-pack-v1.zip")
|
||||
except ValueError as e:
|
||||
assert "downloader will reject" in str(e)
|
||||
else:
|
||||
raise AssertionError("build_pack accepted a .DS_Store the downloader rejects")
|
||||
|
||||
|
||||
def test_double_download_409s(client, monkeypatch):
|
||||
bar = career_routes._venue("bar")
|
||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
|
||||
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||
# Pretend one is already running.
|
||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
|
||||
|
||||
|
||||
# ── gig pre-extraction (the wait between songs) ─────────────────────────────
|
||||
#
|
||||
# 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.
|
||||
# The setlist is known up front, so extract it all while the poster is up.
|
||||
|
||||
def _career_client_with_library(tmp_path, meta_db, dlc, cache):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import routes as career_routes
|
||||
app = FastAPI()
|
||||
career_routes.setup(app, {
|
||||
"config_dir": str(tmp_path),
|
||||
"meta_db": meta_db,
|
||||
"get_dlc_dir": lambda: dlc,
|
||||
"get_sloppak_cache_dir": lambda: cache,
|
||||
})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _write_feedpak(dlc, name, title="T"):
|
||||
"""A minimal but REAL feedpak zip, so resolve_source_dir genuinely unpacks."""
|
||||
import json as _json
|
||||
import zipfile as _zip
|
||||
p = dlc / name
|
||||
with _zip.ZipFile(p, "w") as z:
|
||||
z.writestr("manifest.json", _json.dumps({"title": title, "artist": "A", "arrangements": []}))
|
||||
return p
|
||||
|
||||
|
||||
def test_gig_prepare_extracts_every_song_up_front(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
for n in ("one.feedpak", "two.feedpak", "three.feedpak"):
|
||||
_write_feedpak(dlc, n)
|
||||
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
before = list(cache.iterdir())
|
||||
assert before == [], "nothing unpacked yet"
|
||||
|
||||
res = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["one.feedpak", "two.feedpak", "three.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["ok"] is True
|
||||
assert body["prepared"] == 3, body
|
||||
assert body["failed"] == []
|
||||
# The point of the whole exercise: the set is on disk BEFORE the first note.
|
||||
assert len(list(cache.iterdir())) == 3, "every song of the set must be unpacked"
|
||||
|
||||
|
||||
def test_gig_prepare_is_idempotent_on_a_warm_cache(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "one.feedpak")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
first = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
|
||||
second = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
|
||||
assert first["prepared"] == second["prepared"] == 1
|
||||
assert len(list(cache.iterdir())) == 1, "a re-prepare must not duplicate the unpack"
|
||||
|
||||
|
||||
def test_one_bad_feedpak_does_not_stop_the_set(tmp_path, meta_db):
|
||||
# A corrupt pak in the setlist must not block the gig: the play itself will
|
||||
# surface the error exactly as it does outside a gig. Slow beats blocked.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "good.feedpak")
|
||||
(dlc / "bad.feedpak").write_bytes(b"not a zip at all")
|
||||
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["good.feedpak", "bad.feedpak"]}).json()
|
||||
assert body["ok"] is True, "a bad pak must not fail the whole prepare"
|
||||
assert body["prepared"] == 1
|
||||
assert body["failed"] == ["bad.feedpak"]
|
||||
|
||||
|
||||
def test_gig_prepare_degrades_without_a_library(tmp_path, meta_db, client):
|
||||
# The stock fixture's context has no dlc/cache resolvers. That must be a
|
||||
# graceful no-op, not a 500 — pre-extraction is an optimisation and can
|
||||
# never be the reason a gig won't start.
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["x.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["prepared"] == 0
|
||||
|
||||
|
||||
def test_gig_prepare_empty_setlist(tmp_path, meta_db, client):
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": []})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"ok": True, "prepared": 0, "failed": []}
|
||||
|
||||
|
||||
def test_prepare_rejects_a_non_list_songs_value(tmp_path, meta_db, client):
|
||||
# A str is iterable: without the list check, "abc" would prepare three
|
||||
# one-character "songs".
|
||||
for bad in ("abc", 42, {"a": 1}, None):
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": bad})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["prepared"] == 0
|
||||
|
||||
|
||||
def test_prepare_ignores_non_string_and_blank_entries(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "good.feedpak")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["good.feedpak", "", " ", 7, None, {"x": 1}]}).json()
|
||||
assert body["prepared"] == 1
|
||||
assert body["failed"] == []
|
||||
|
||||
|
||||
def test_prepare_caps_the_setlist(tmp_path, meta_db):
|
||||
# This endpoint unpacks zips — an arbitrary caller must not be able to ask for
|
||||
# unbounded work.
|
||||
#
|
||||
# The first version of this test asserted `prepared == 0` against a fixture
|
||||
# with NO library: the endpoint exits before extraction there, so it passed
|
||||
# whether or not the cap existed. Give it a real library, ask for far more than
|
||||
# the cap, and assert the endpoint only ever considered MAX_GIG_SONGS of them.
|
||||
import routes as career_routes
|
||||
assert career_routes.MAX_GIG_SONGS <= 64
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
n = career_routes.MAX_GIG_SONGS + 50
|
||||
# None of these exist, so every song the endpoint LOOKS AT lands in `failed`.
|
||||
# That makes `failed` an exact count of how many it considered.
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": [f"missing{i}.feedpak" for i in range(n)]}).json()
|
||||
assert body["prepared"] == 0
|
||||
assert len(body["failed"]) == career_routes.MAX_GIG_SONGS, (
|
||||
f"the endpoint must consider at most MAX_GIG_SONGS "
|
||||
f"({career_routes.MAX_GIG_SONGS}), not all {n}"
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_refuses_to_escape_the_library(tmp_path, meta_db):
|
||||
# resolve_source_dir() does a bare `dlc_root / filename` with no containment
|
||||
# guard, so a crafted path would walk straight out of the library. Every
|
||||
# filename must go through _resolve_dlc_path first.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
(tmp_path / "outside.feedpak").write_bytes(b"secret")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
for evil in ("../outside.feedpak", "..\\outside.feedpak",
|
||||
"a/../../outside.feedpak", "/etc/passwd", "C:/Windows/x.feedpak"):
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": [evil]}).json()
|
||||
assert body["prepared"] == 0, f"{evil!r} must never be prepared"
|
||||
assert body["failed"] == [evil]
|
||||
# Nothing outside the library may have been unpacked.
|
||||
assert list(cache.iterdir()) == []
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"""VST-pack slicing (tools/content_packs.build_vst_pack): each platform pack
|
||||
keeps only its own binaries + the shared bundle files, drops the rest, and is
|
||||
reproducible."""
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from tools import content_packs
|
||||
|
||||
|
||||
def _fake_vst_tree(root: Path):
|
||||
# One fat .vst3 with all three platform binaries + shared files, plus a
|
||||
# src/ build tree that must never ship.
|
||||
c = root / "amps" / "Foo.vst3" / "Contents"
|
||||
(c / "MacOS").mkdir(parents=True)
|
||||
(c / "x86_64-win").mkdir(parents=True)
|
||||
(c / "x86_64-linux").mkdir(parents=True)
|
||||
(c / "Resources").mkdir(parents=True)
|
||||
(c / "MacOS" / "Foo").write_bytes(b"mac-binary")
|
||||
(c / "x86_64-win" / "Foo.vst3").write_bytes(b"win-binary")
|
||||
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux-binary")
|
||||
(c / "Info.plist").write_bytes(b"<plist/>")
|
||||
(c / "Resources" / "moduleinfo.json").write_bytes(b"{}")
|
||||
(root / "src" / "build").mkdir(parents=True)
|
||||
(root / "src" / "build" / "junk.o").write_bytes(b"objfile")
|
||||
|
||||
|
||||
def _names(zip_path):
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
return set(zf.namelist())
|
||||
|
||||
|
||||
def test_slice_keeps_target_platform_and_shared_drops_foreign(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
content_packs.build_vst_pack(root, tmp_path / "mac.zip", "mac")
|
||||
names = _names(tmp_path / "mac.zip")
|
||||
|
||||
base = "amps/Foo.vst3/Contents"
|
||||
assert f"{base}/MacOS/Foo" in names # target binary kept
|
||||
assert f"{base}/Info.plist" in names # shared kept
|
||||
assert f"{base}/Resources/moduleinfo.json" in names # shared kept
|
||||
assert f"{base}/x86_64-win/Foo.vst3" not in names # foreign dropped
|
||||
assert f"{base}/x86_64-linux/Foo.so" not in names # foreign dropped
|
||||
assert not any(n.startswith("src/") for n in names) # build trees never ship
|
||||
|
||||
|
||||
def test_each_platform_gets_its_own_binary(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
wanted = {"mac": "MacOS/Foo", "win": "x86_64-win/Foo.vst3", "linux": "x86_64-linux/Foo.so"}
|
||||
for plat, rel in wanted.items():
|
||||
content_packs.build_vst_pack(root, tmp_path / f"{plat}.zip", plat)
|
||||
names = _names(tmp_path / f"{plat}.zip")
|
||||
assert f"amps/Foo.vst3/Contents/{rel}" in names
|
||||
others = [v for k, v in wanted.items() if k != plat]
|
||||
for o in others:
|
||||
assert f"amps/Foo.vst3/Contents/{o}" not in names
|
||||
|
||||
|
||||
def test_slice_is_reproducible(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
a = content_packs.build_vst_pack(root, tmp_path / "a.zip", "linux")
|
||||
b = content_packs.build_vst_pack(root, tmp_path / "b.zip", "linux")
|
||||
assert a == b and a["sha256"]
|
||||
|
||||
|
||||
def test_slice_pins_create_system_for_cross_runner_reproducibility(tmp_path, monkeypatch):
|
||||
# ZipInfo defaults create_system from the host OS (0 on Windows, 3 on Unix),
|
||||
# and it lands in the central directory — so without an explicit pin the same
|
||||
# tree hashes differently on a Windows runner, breaking the precomputable-hash
|
||||
# guarantee exactly where it matters (native .vst3 are built on Windows). A
|
||||
# same-machine reproducibility test can't catch that; simulate win32 and
|
||||
# assert the pin forces 3 regardless.
|
||||
monkeypatch.setattr(zipfile.sys, "platform", "win32")
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
content_packs.build_vst_pack(root, tmp_path / "w.zip", "linux")
|
||||
with zipfile.ZipFile(tmp_path / "w.zip") as zf:
|
||||
assert all(i.create_system == 3 for i in zf.infolist())
|
||||
|
||||
|
||||
def test_unknown_platform_rejected(tmp_path):
|
||||
root = tmp_path / "vst"
|
||||
_fake_vst_tree(root)
|
||||
try:
|
||||
content_packs.build_vst_pack(root, tmp_path / "x.zip", "bsd")
|
||||
except ValueError as e:
|
||||
assert "unknown platform" in str(e)
|
||||
else:
|
||||
raise AssertionError("build_vst_pack accepted an unknown platform")
|
||||
@@ -137,81 +137,6 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
|
||||
assert "ignore.zip" not in seen
|
||||
|
||||
|
||||
# ── 2b. directory-signature fast path (skip the full re-stat) ────────────────
|
||||
|
||||
def test_dir_signature_fast_path_skips_unchanged_tree(tmp_path, scan_server):
|
||||
"""After a full scan records the library-dir signature, a second scan with
|
||||
an unchanged tree takes the fast path and does NOT re-glob/extract — but a
|
||||
forced scan (manual Refresh) always does the full pass, and a new song
|
||||
(which bumps the dir mtime) reverts to a full pass on its own."""
|
||||
import unittest.mock as mock
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
(dlc / "a.feedpak").write_bytes(b"")
|
||||
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
|
||||
|
||||
scan = importlib.import_module("scan")
|
||||
seen: list[str] = []
|
||||
|
||||
def mock_extract(f, dlc_dir):
|
||||
seen.append(f.name)
|
||||
return {"title": f.name, "artist": "", "album": ""}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
# 1) first pass: full scan, extracts a.feedpak (+ seeded builtins),
|
||||
# records the signature
|
||||
scan.background_scan()
|
||||
assert "a.feedpak" in seen
|
||||
assert scan._dir_signature_file().exists()
|
||||
|
||||
# 2) unchanged tree: fast path — no glob, no extraction at all
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert seen == []
|
||||
assert scan.status()["stage"] == "complete"
|
||||
|
||||
# 3) a new song bumps the dlc mtime → signature mismatch → full pass
|
||||
# picks it up on its own (no manual Refresh needed for adds)
|
||||
(dlc / "b.feedpak").write_bytes(b"")
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert "b.feedpak" in seen
|
||||
|
||||
# 4) force=True (Refresh) bypasses the fast path even on a settled tree:
|
||||
# with the signature now current, a plain scan skips, a forced one lists
|
||||
seen.clear()
|
||||
scan.background_scan() # fast path
|
||||
assert seen == []
|
||||
forced_listed = []
|
||||
real_delete_missing = scan.appstate.meta_db.delete_missing
|
||||
def _spy(files):
|
||||
forced_listed.append(set(files))
|
||||
return real_delete_missing(files)
|
||||
with mock.patch.object(scan.appstate.meta_db, "delete_missing", new=_spy):
|
||||
scan.background_scan(force=True)
|
||||
assert forced_listed, "force=True must run the full listing pass"
|
||||
|
||||
|
||||
def test_dir_signature_tracks_directory_form_song_own_dir(tmp_path):
|
||||
"""A directory-form song (loose folder / directory bundle) records its OWN
|
||||
directory in the signature, so an in-place file change inside it — which
|
||||
bumps that folder's mtime but not its parent's — invalidates the fast path.
|
||||
A file-form sloppak (a plain .feedpak zip) is not a dir and adds nothing."""
|
||||
scan = importlib.import_module("scan")
|
||||
dlc = tmp_path / "dlc"
|
||||
(dlc / "packs").mkdir(parents=True)
|
||||
loose = dlc / "packs" / "my_loose_song" # directory-form song
|
||||
loose.mkdir()
|
||||
zipped = dlc / "packs" / "zipped.feedpak" # file-form song
|
||||
zipped.write_bytes(b"")
|
||||
|
||||
rels = scan._library_dirs([loose, zipped], dlc)
|
||||
assert "packs/my_loose_song" in rels, "directory-form song must track its own dir"
|
||||
assert "packs" in rels and "." in rels
|
||||
assert "packs/zipped.feedpak" not in rels, "a file-form sloppak is not a tracked dir"
|
||||
|
||||
|
||||
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Regression: the GP→arrangement-XML writers must pin UTF-8.
|
||||
|
||||
A bare ``Path.write_text(xml_str)`` uses the platform's *default* text
|
||||
encoding. On Windows that is cp1252, which encodes a non-ASCII metadata
|
||||
character — e.g. the © in an album name like "Chrysalis©1982" — as the lone
|
||||
byte 0xA9. The XML is then read back as UTF-8 (expat's default), where 0xA9
|
||||
is an invalid start byte, so parsing dies with
|
||||
|
||||
not well-formed (invalid token): line N, column 22
|
||||
|
||||
CI runs on Linux (UTF-8 default), so the bug is invisible there and a plain
|
||||
functional test would pass on the old code too. These assertions instead pin
|
||||
the locale-independent contract directly.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import gp2rs
|
||||
import gp2rs_gpx
|
||||
|
||||
|
||||
def test_arrangement_xml_writes_specify_utf8():
|
||||
# Every write of the arrangement XML string must pass encoding="utf-8"
|
||||
# so non-ASCII metadata survives regardless of the host locale.
|
||||
for mod in (gp2rs, gp2rs_gpx):
|
||||
src = inspect.getsource(mod)
|
||||
bare = re.findall(r"\.write_text\(\s*xml_str\s*\)", src)
|
||||
assert not bare, (
|
||||
f"{mod.__name__}: XML write must pass encoding=\"utf-8\" — a bare "
|
||||
f"write_text() uses the platform default (cp1252 on Windows) and "
|
||||
f"mangles non-ASCII metadata into invalid UTF-8"
|
||||
)
|
||||
assert 'write_text(xml_str, encoding="utf-8")' in src, (
|
||||
f"{mod.__name__}: expected a UTF-8-pinned arrangement XML write"
|
||||
)
|
||||
|
||||
|
||||
def test_utf8_write_round_trips_non_ascii_album():
|
||||
# The behavioural end of the contract: a © album name written as UTF-8
|
||||
# parses cleanly and reads back intact (the cp1252 write does not).
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
xml_str = (
|
||||
'<?xml version="1.0"?>\n<song>\n'
|
||||
" <albumName>Chrysalis©1982</albumName>\n</song>\n"
|
||||
)
|
||||
path = Path(tempfile.mkdtemp()) / "arr.xml"
|
||||
path.write_text(xml_str, encoding="utf-8")
|
||||
root = ET.parse(path).getroot()
|
||||
assert root.findtext("albumName") == "Chrysalis©1982"
|
||||
+3
-142
@@ -38,25 +38,18 @@ from gp_autosync import (
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gpif_bytes(asset_id: str = "abc-123", registry=None) -> bytes:
|
||||
"""`registry` maps Asset id -> EmbeddedFilePath, mirroring real GP8 files."""
|
||||
def _gpif_bytes(asset_id: str = "abc-123") -> bytes:
|
||||
root = ET.Element("GPIF")
|
||||
bt = ET.SubElement(root, "BackingTrack")
|
||||
ET.SubElement(bt, "AssetId").text = asset_id
|
||||
if registry:
|
||||
assets = ET.SubElement(root, "Assets")
|
||||
for aid, path in registry.items():
|
||||
a = ET.SubElement(assets, "Asset")
|
||||
a.set("id", aid)
|
||||
ET.SubElement(a, "EmbeddedFilePath").text = path
|
||||
return ET.tostring(root)
|
||||
|
||||
|
||||
def _make_gp_zip(asset_id="abc-123", ogg_stems=("abc-123",),
|
||||
asset_ext=".ogg", registry=None) -> zipfile.ZipFile:
|
||||
asset_ext=".ogg") -> zipfile.ZipFile:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", _gpif_bytes(asset_id, registry))
|
||||
zf.writestr("Content/score.gpif", _gpif_bytes(asset_id))
|
||||
for stem in ogg_stems:
|
||||
zf.writestr(f"Content/Assets/{stem}{asset_ext}", b"fake-audio")
|
||||
buf.seek(0)
|
||||
@@ -291,135 +284,3 @@ def test_extract_sync_points_empty_when_no_bars():
|
||||
root = ET.Element("GPIF") # no MasterBars
|
||||
_, wp, times, sr, hop = _identity_setup()
|
||||
assert _extract_sync_points(wp, root, times, times, sr, hop, 4) == []
|
||||
|
||||
|
||||
# ── AssetId is a key into <Assets>, not a filename stem ──────────────────────
|
||||
# Real GP8 files name embedded audio by hash while AssetId is a small
|
||||
# integer, so the stem match never hit: every such file warned and fell
|
||||
# through to "first audio asset". Silently correct with ONE asset; with two,
|
||||
# a backing track declaring id 1 resolved to asset 0 — the wrong recording.
|
||||
|
||||
_REAL_SHAPE = {"0": "Content/Assets/1312f2aa-10ee-5f35-a4d5-e999eee1d9d0.mp3"}
|
||||
|
||||
|
||||
def test_asset_id_resolves_through_the_registry_not_the_stem():
|
||||
zf = _make_gp_zip(
|
||||
asset_id="0",
|
||||
ogg_stems=("1312f2aa-10ee-5f35-a4d5-e999eee1d9d0",),
|
||||
asset_ext=".mp3",
|
||||
registry=_REAL_SHAPE,
|
||||
)
|
||||
stem, path = _resolve_audio_asset(zf)
|
||||
assert path == "Content/Assets/1312f2aa-10ee-5f35-a4d5-e999eee1d9d0.mp3"
|
||||
assert stem == "1312f2aa-10ee-5f35-a4d5-e999eee1d9d0"
|
||||
|
||||
|
||||
def test_the_second_asset_is_reachable():
|
||||
"""The actual bug: id 1 used to resolve to asset 0."""
|
||||
zf = _make_gp_zip(
|
||||
asset_id="1",
|
||||
ogg_stems=("first-track", "second-track"),
|
||||
registry={
|
||||
"0": "Content/Assets/first-track.ogg",
|
||||
"1": "Content/Assets/second-track.ogg",
|
||||
},
|
||||
)
|
||||
stem, path = _resolve_audio_asset(zf)
|
||||
assert path == "Content/Assets/second-track.ogg", "declared id 1 must win"
|
||||
assert stem == "second-track"
|
||||
|
||||
|
||||
def test_registry_entry_pointing_at_a_missing_file_falls_through():
|
||||
zf = _make_gp_zip(
|
||||
asset_id="0",
|
||||
ogg_stems=("real-track",),
|
||||
registry={"0": "Content/Assets/deleted-track.ogg"},
|
||||
)
|
||||
stem, path = _resolve_audio_asset(zf)
|
||||
assert path == "Content/Assets/real-track.ogg"
|
||||
|
||||
|
||||
def test_backslash_separators_in_the_registry_are_normalised():
|
||||
zf = _make_gp_zip(
|
||||
asset_id="0",
|
||||
ogg_stems=("winpath",),
|
||||
registry={"0": r"Content\Assets\winpath.ogg"},
|
||||
)
|
||||
_, path = _resolve_audio_asset(zf)
|
||||
assert path == "Content/Assets/winpath.ogg"
|
||||
|
||||
|
||||
def test_registry_prefers_ogg_among_same_stem_duplicates():
|
||||
"""Quality behaviour is preserved: OGG is copied out, others transcoded."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", _gpif_bytes(
|
||||
"0", {"0": "Content/Assets/dual.mp3"}))
|
||||
zf.writestr("Content/Assets/dual.mp3", b"fake")
|
||||
zf.writestr("Content/Assets/dual.ogg", b"fake")
|
||||
buf.seek(0)
|
||||
_, path = _resolve_audio_asset(zipfile.ZipFile(buf))
|
||||
assert path.endswith(".ogg")
|
||||
|
||||
|
||||
def test_a_malformed_registry_does_not_break_resolution():
|
||||
for reg in ({"0": ""}, {"9": "Content/Assets/other.ogg"}, {}):
|
||||
zf = _make_gp_zip(asset_id="0", ogg_stems=("fallback",), registry=reg)
|
||||
_, path = _resolve_audio_asset(zf)
|
||||
assert path == "Content/Assets/fallback.ogg"
|
||||
|
||||
|
||||
def test_legacy_stem_match_still_works_without_a_registry():
|
||||
"""Files whose stem IS the id keep resolving — step 2 of the ladder."""
|
||||
zf = _make_gp_zip(asset_id="abc-123", ogg_stems=("zzz", "abc-123"))
|
||||
stem, path = _resolve_audio_asset(zf)
|
||||
assert stem == "abc-123"
|
||||
assert path == "Content/Assets/abc-123.ogg"
|
||||
|
||||
|
||||
def test_a_same_stem_file_in_another_directory_cannot_stand_in():
|
||||
"""The registry names a PATH, not just a name.
|
||||
|
||||
Resolution matches on stem so a format variant of the same recording can
|
||||
win, but an unrelated file that merely shares the stem must not satisfy
|
||||
the declaration — that substitution is what the registry lookup exists to
|
||||
prevent. The declared asset is genuinely absent here, so the right answer
|
||||
is the documented fall-through, not the decoy.
|
||||
|
||||
ZIP order matters to this test: `real.ogg` is written FIRST so the
|
||||
fall-through target differs from the decoy. Otherwise both the fixed and
|
||||
unfixed code return the same file and the test proves nothing.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", _gpif_bytes(
|
||||
"0", {"0": "Content/Audio/track.ogg"}))
|
||||
zf.writestr("Content/Assets/real.ogg", b"fake") # fall-through target
|
||||
zf.writestr("Content/Assets/track.ogg", b"decoy") # shares the stem only
|
||||
buf.seek(0)
|
||||
_, path = _resolve_audio_asset(zipfile.ZipFile(buf))
|
||||
assert path == "Content/Assets/real.ogg", (
|
||||
"a same-stem file in a directory the registry never named must not "
|
||||
"satisfy the declaration"
|
||||
)
|
||||
|
||||
|
||||
def test_the_declared_directory_still_resolves_its_own_format_variants():
|
||||
"""The directory constraint must not cost us the OGG preference.
|
||||
|
||||
The shallower decoy is written FIRST, so unfixed code (which searches
|
||||
every directory) picks it and this test fails.
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", _gpif_bytes(
|
||||
"0", {"0": "Content/Assets/nested/take.mp3"}))
|
||||
zf.writestr("Content/Assets/take.ogg", b"decoy-one-level-up")
|
||||
zf.writestr("Content/Assets/nested/take.mp3", b"declared")
|
||||
zf.writestr("Content/Assets/nested/take.ogg", b"same-take-lossless")
|
||||
buf.seek(0)
|
||||
stem, path = _resolve_audio_asset(zipfile.ZipFile(buf))
|
||||
assert path == "Content/Assets/nested/take.ogg", (
|
||||
"the OGG variant in the DECLARED directory wins over a shallower decoy"
|
||||
)
|
||||
assert stem == "take"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Wire-compatibility coverage for selectable drum parts."""
|
||||
|
||||
from routers.ws_highway import _drum_part_id_for_wire
|
||||
|
||||
|
||||
def test_single_synthesized_part_keeps_legacy_frame_without_part_id():
|
||||
parts = [{"id": "drums", "name": "Drums", "drum_tab": {}}]
|
||||
assert _drum_part_id_for_wire(parts, "drums") is None
|
||||
|
||||
|
||||
def test_multiple_parts_expose_selected_part_id():
|
||||
parts = [
|
||||
{"id": "drums", "name": "Drums", "drum_tab": {}},
|
||||
{"id": "drums-2", "name": "Aux", "drum_tab": {}},
|
||||
]
|
||||
assert _drum_part_id_for_wire(parts, "drums-2") == "drums-2"
|
||||
assert _drum_part_id_for_wire(parts, None) is None
|
||||
@@ -1,721 +0,0 @@
|
||||
"""Instrument-aware tuning in the library (the KwasimodoZAZA bass report).
|
||||
|
||||
A song's BASS chart is often tuned differently from its guitar chart, but the
|
||||
library indexed exactly one guitar-first tuning per song — so a bass player
|
||||
filtering "Drop D" got songs whose GUITAR is in Drop D, and playlists built
|
||||
that way were wrong.
|
||||
|
||||
These tests round-trip through the real extractors, the real scanner
|
||||
derivation, the real SQLite schema/migration, and the real HTTP surface. The
|
||||
only thing stubbed is metadata EXTRACTION in the scan tests (the production
|
||||
process pool can't reach an in-process mock) — never the code under test.
|
||||
|
||||
Real-library notes, all confirmed against actual pack contents:
|
||||
|
||||
* Bass arrangements usually store SIX-element offset arrays even when the
|
||||
chart is a 4-string part — slots 4-5 are PADDING (no bass chart in the
|
||||
corpus references string index 4 or 5). So bass offsets are truncated to 4
|
||||
before naming or grouping. The feedpak spec has no string-count field, so 4
|
||||
is a documented default, not a read value.
|
||||
* AC/DC "Girls Got Rhythm" stores [5,5,5,5,4,4] — every string up a fourth,
|
||||
which no bassist plays. That is BAD DATA, and it must never be NAMED, or the
|
||||
library sends a player to retune to a tuning that does not exist.
|
||||
* Covet "Shibuya" (custom guitar tuning, dead-standard bass) is the headline
|
||||
regression: the tester's bug in a single song.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
from scan_worker import _extract_meta_for_file
|
||||
from tunings import (
|
||||
PERSPECTIVES, bass_offsets_are_plausible, bass_tuning_key, bass_tuning_name,
|
||||
chart_is_playable_in, normalize_bass_offsets, perspective_tuning_key,
|
||||
tuning_name,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _pack(root, name, arrangements):
|
||||
"""A directory-form pack whose manifest carries per-arrangement tunings."""
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(yaml.safe_dump({
|
||||
"title": name, "artist": "A", "duration": 100,
|
||||
"arrangements": arrangements, "stems": [],
|
||||
}), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _put(server_mod, *, filename, title, tuning_name_="E Standard",
|
||||
tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
|
||||
bass_tuning_name="", bass_tuning_sort_key=0, bass_tuning_offsets="",
|
||||
bass_tuning_key=""):
|
||||
server_mod.meta_db.put(filename, 1.0, 1, {
|
||||
"title": title, "artist": "A", "album": "A - LP", "year": "2010",
|
||||
"duration": 200.0, "tuning": tuning_name_, "arrangements": [],
|
||||
"has_lyrics": False, "format": "sloppak", "stem_ids": [],
|
||||
"tuning_name": tuning_name_,
|
||||
"tuning_sort_key": tuning_sort_key,
|
||||
"tuning_offsets": tuning_offsets,
|
||||
"bass_tuning_name": bass_tuning_name,
|
||||
"bass_tuning_sort_key": bass_tuning_sort_key,
|
||||
"bass_tuning_offsets": bass_tuning_offsets,
|
||||
"bass_tuning_key": bass_tuning_key,
|
||||
})
|
||||
|
||||
|
||||
# ── 1. Extraction: sloppak ───────────────────────────────────────────────────
|
||||
|
||||
def test_sloppak_extract_indexes_both_tunings_when_they_differ(tmp_path):
|
||||
"""The reported case: guitar down a step, bass in standard. BOTH must be
|
||||
indexed — previously only the guitar tuning survived."""
|
||||
d = _pack(tmp_path, "differ.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
|
||||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = sloppak_mod.extract_meta(d)
|
||||
assert meta["tuning_offsets"] == [-2, 0, 0, -1, -2, 0]
|
||||
assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_sloppak_extract_leaves_bass_absent_without_bass_arrangement(tmp_path):
|
||||
"""No bass chart → None, NOT a copy of the guitar tuning. The library
|
||||
falls back explicitly, so 'no bass part' stays distinguishable."""
|
||||
d = _pack(tmp_path, "nobass.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
{"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
])
|
||||
meta = sloppak_mod.extract_meta(d)
|
||||
assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
|
||||
assert meta["bass_tuning_offsets"] is None
|
||||
|
||||
|
||||
def test_sloppak_extract_bass_wins_over_guitar_first_ordering(tmp_path):
|
||||
"""The bass entry is listed FIRST in the manifest; the song tuning must
|
||||
still be the guitar's while the bass column takes the bass entry — the two
|
||||
selections are independent, not 'first wins'."""
|
||||
d = _pack(tmp_path, "order.sloppak", [
|
||||
{"name": "Bass", "tuning": [-4, -4, -4, -4, -4, -4]},
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = sloppak_mod.extract_meta(d)
|
||||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, -4, -4]
|
||||
|
||||
|
||||
def test_sloppak_extract_ignores_bass_arrangement_without_a_tuning(tmp_path):
|
||||
"""A bass chart that authors no tuning gives us nothing to index; the
|
||||
column stays empty rather than defaulting to a wrong all-zeros."""
|
||||
d = _pack(tmp_path, "untuned.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
{"name": "Bass"},
|
||||
])
|
||||
assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] is None
|
||||
|
||||
|
||||
def test_sloppak_extract_falls_back_to_an_alt_bass_chart(tmp_path):
|
||||
"""Only a "Bass 2" chart exists. Using it beats reporting the guitar
|
||||
tuning as the player's bass tuning."""
|
||||
d = _pack(tmp_path, "altbass.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Bass 2", "tuning": [-2, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] == [-2, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
# ── 2. Scanner derivation (name / sort key / offsets string) ─────────────────
|
||||
|
||||
def test_scan_worker_derives_bass_columns_like_the_guitar_ones(tmp_path):
|
||||
"""Guitar columns keep all six strings; bass columns are TRUNCATED to the
|
||||
bass's four (the stored tail is padding — see tunings.normalize_bass_offsets)."""
|
||||
d = _pack(tmp_path, "derive.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["tuning_name"] == "D Standard"
|
||||
assert meta["tuning_sort_key"] == -12
|
||||
assert meta["tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
|
||||
assert meta["bass_tuning_name"] == "E Standard"
|
||||
assert meta["bass_tuning_sort_key"] == 0
|
||||
assert meta["bass_tuning_offsets"] == "0 0 0 0"
|
||||
# Canonical key = absolute open pitches of a 4-string bass in standard.
|
||||
assert meta["bass_tuning_key"] == "bass:28:33:38:43"
|
||||
|
||||
|
||||
def test_bass_padding_is_truncated_before_naming_and_grouping(tmp_path):
|
||||
"""The padded tail must never reach the namer or the group key: a bass
|
||||
stored six-wide and the same tuning stored four-wide must produce
|
||||
IDENTICAL indexed columns."""
|
||||
six = _extract_meta_for_file(_pack(tmp_path, "six.sloppak", [
|
||||
{"name": "Bass", "tuning": [-2, 0, 0, 0, 0, 0]}]))
|
||||
four = _extract_meta_for_file(_pack(tmp_path, "four.sloppak", [
|
||||
{"name": "Bass", "tuning": [-2, 0, 0, 0]}]))
|
||||
for col in ("bass_tuning_name", "bass_tuning_offsets",
|
||||
"bass_tuning_sort_key", "bass_tuning_key"):
|
||||
assert six[col] == four[col], col
|
||||
assert six["bass_tuning_name"] == "Drop D"
|
||||
|
||||
|
||||
def test_scan_worker_bass_columns_empty_without_a_bass_arrangement(tmp_path):
|
||||
"""Empty string, never None: '' is the indexed 'we looked, no bass chart'
|
||||
state, while NULL means 'never extracted' and triggers a re-scan."""
|
||||
d = _pack(tmp_path, "nobass2.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["bass_tuning_name"] == ""
|
||||
assert meta["bass_tuning_sort_key"] == 0
|
||||
assert meta["bass_tuning_offsets"] == ""
|
||||
|
||||
|
||||
def test_implausible_bass_tuning_is_never_named(tmp_path):
|
||||
"""Real library data, and it is BAD DATA: AC/DC "Girls Got Rhythm" stores
|
||||
a bass tuning of [5,5,5,5,4,4] — every string up a perfect fourth, which
|
||||
no bassist plays (roughly double string tension), on a song whose guitar
|
||||
chart is dead standard.
|
||||
|
||||
Truncation alone would leave [5,5,5,5] = "all strings up a 4th", which the
|
||||
namer WOULD happily name. Naming it would send a player off to retune to a
|
||||
tuning that does not exist, so the plausibility guard must refuse: bassists
|
||||
tune down, essentially never up."""
|
||||
d = _pack(tmp_path, "weird.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Bass", "tuning": [5, 5, 5, 5, 4, 4]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["bass_tuning_name"] == "Custom Tuning"
|
||||
assert meta["bass_tuning_offsets"] == "5 5 5 5"
|
||||
assert meta["bass_tuning_sort_key"] == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offsets", [
|
||||
[5, 5, 5, 5], [5, 5, 5, 5, 4, 4], [2, 2, 2, 2], [12, 12, 12, 12],
|
||||
])
|
||||
def test_up_tuned_bass_offsets_are_refused_by_the_guard(offsets):
|
||||
"""Anything above +1 semitone is data we do not trust. Note the namer
|
||||
ALONE would name several of these ([2,2,2,2] -> "F# Standard"), which is
|
||||
exactly the retune-to-nowhere the guard exists to prevent."""
|
||||
norm = normalize_bass_offsets(offsets)
|
||||
assert bass_offsets_are_plausible(norm) is False
|
||||
assert bass_tuning_name(norm) == "Custom Tuning"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offsets,expected", [
|
||||
([0, 0, 0, 0], "E Standard"), # standard
|
||||
([-1, -1, -1, -1], "Eb Standard"), # down a semitone
|
||||
([-2, 0, 0, 0], "Drop D"), # drop
|
||||
([1, 1, 1, 1], "F Standard"), # +1 is the plausible ceiling, still named
|
||||
])
|
||||
def test_plausible_bass_tunings_are_still_named(offsets, expected):
|
||||
"""The guard must not over-fire: real down-tunings, standard, and the +1
|
||||
ceiling all keep their names."""
|
||||
assert bass_tuning_name(offsets) == expected
|
||||
|
||||
|
||||
# ── 3. Storage round-trip + the pre-migration re-extract marker ──────────────
|
||||
|
||||
def test_put_get_round_trips_the_bass_columns(server_mod):
|
||||
_put(server_mod, filename="rt.sloppak", title="RT",
|
||||
tuning_name_="D Standard", tuning_sort_key=-12,
|
||||
tuning_offsets="-2 -2 -2 -2 -2 -2",
|
||||
bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
|
||||
got = server_mod.meta_db.get("rt.sloppak", 1.0, 1)
|
||||
assert got["tuning_name"] == "D Standard"
|
||||
assert got["bass_tuning_name"] == "E Standard"
|
||||
assert got["bass_tuning_offsets"] == "0 0 0 0 0 0"
|
||||
|
||||
|
||||
def test_put_never_writes_null_bass_columns(server_mod):
|
||||
"""A freshly-scanned row is by definition extracted, so even a song with
|
||||
no bass chart stores '' — otherwise it would look pre-migration forever
|
||||
and the scanner would re-extract it on every single pass."""
|
||||
_put(server_mod, filename="fresh.sloppak", title="Fresh")
|
||||
row = server_mod.meta_db.conn.execute(
|
||||
"SELECT bass_tuning_name FROM songs WHERE filename = 'fresh.sloppak'").fetchone()
|
||||
assert row[0] == ""
|
||||
assert server_mod.meta_db.get("fresh.sloppak", 1.0, 1)["bass_tuning_name"] == ""
|
||||
|
||||
|
||||
def test_pre_migration_row_reads_back_as_null(server_mod):
|
||||
"""A row written before the columns existed (simulated with raw SQL that
|
||||
omits them) reads back None — the marker the scanner keys its re-extract
|
||||
on. If this ever became '' the backfill would silently never run."""
|
||||
server_mod.meta_db.conn.execute(
|
||||
"INSERT 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) "
|
||||
"VALUES ('old.sloppak', 1.0, 1, 'Old', 'A', 'A - LP', '2010', 200.0, "
|
||||
"'E Standard', '[]', 0, 'sloppak', 0, '[]', 'E Standard', 0, '0 0 0 0 0 0')")
|
||||
server_mod.meta_db.conn.commit()
|
||||
got = server_mod.meta_db.get("old.sloppak", 1.0, 1)
|
||||
assert got["bass_tuning_name"] is None
|
||||
# Same for the canonical key: coalescing this to '' would make the
|
||||
# scanner's re-extract check unfireable and strand the backfill.
|
||||
assert got["bass_tuning_key"] is None
|
||||
|
||||
|
||||
def test_a_row_missing_only_the_canonical_key_still_re_extracts(server_mod):
|
||||
"""A row scanned by an EARLIER build of this feature has bass_tuning_name
|
||||
but no bass_tuning_key. It must still be re-queued, or its custom tunings
|
||||
would group on the old serialization-dependent key forever."""
|
||||
_put(server_mod, filename="halfway.sloppak", title="Halfway",
|
||||
bass_tuning_name="Drop D", bass_tuning_offsets="-2 0 0 0")
|
||||
server_mod.meta_db.conn.execute(
|
||||
"UPDATE songs SET bass_tuning_key = NULL WHERE filename = 'halfway.sloppak'")
|
||||
server_mod.meta_db.conn.commit()
|
||||
cached = server_mod.meta_db.get("halfway.sloppak", 1.0, 1)
|
||||
assert cached["bass_tuning_name"] == "Drop D"
|
||||
assert cached["bass_tuning_key"] is None # → the scanner re-queues it
|
||||
|
||||
|
||||
# ── 4. The migration actually backfills (the highest-risk gap) ───────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def scan_server(tmp_path, monkeypatch, isolate_logging, reset_scan_state):
|
||||
"""Server with the background scan forced in-process (see
|
||||
test_feedpak_extension.py::scan_server — the production spawn pool can't
|
||||
reach an in-process mock)."""
|
||||
import concurrent.futures
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
import scan as scan_mod
|
||||
monkeypatch.setattr(
|
||||
scan_mod, "_make_scan_executor",
|
||||
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
|
||||
)
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_existing_library_backfills_bass_tuning_on_next_scan(tmp_path, scan_server):
|
||||
"""END TO END for every CURRENT user: a settled library whose rows predate
|
||||
the bass columns must re-extract on the next scan.
|
||||
|
||||
Both guards are exercised together — the row-level "bass column is NULL →
|
||||
re-queue" AND the tree-signature fast path, which on an unchanged library
|
||||
would otherwise skip the listing pass entirely and strand the backfill.
|
||||
Then a second scan must NOT re-extract (the backfill converges, it doesn't
|
||||
re-scan the whole library every launch).
|
||||
"""
|
||||
import unittest.mock as mock
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
(dlc / "song.feedpak").write_bytes(b"")
|
||||
# json.dumps, not %s: a Windows path interpolated raw produces invalid JSON
|
||||
# escapes (\U, \d), the config silently fails to parse, and the scan then
|
||||
# reports "no DLC folder configured" and extracts nothing.
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"dlc_dir": str(dlc)}), encoding="utf-8")
|
||||
|
||||
scan = importlib.import_module("scan")
|
||||
seen: list[str] = []
|
||||
|
||||
def mock_extract(f, dlc_dir):
|
||||
seen.append(f.name)
|
||||
return {"title": f.name, "artist": "A", "album": "",
|
||||
"bass_tuning_name": "Drop D", "bass_tuning_sort_key": -2,
|
||||
"bass_tuning_offsets": "-2 0 0 0 0 0"}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
scan.background_scan()
|
||||
assert "song.feedpak" in seen
|
||||
|
||||
# Simulate the pre-migration state: the row exists and is otherwise
|
||||
# fresh (mtime/size match), but its bass columns were never extracted.
|
||||
scan.appstate.meta_db.conn.execute(
|
||||
"UPDATE songs SET bass_tuning_name = NULL, bass_tuning_sort_key = NULL, "
|
||||
"bass_tuning_offsets = NULL")
|
||||
scan.appstate.meta_db.conn.commit()
|
||||
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert "song.feedpak" in seen, (
|
||||
"a row with NULL bass columns must re-extract — otherwise no "
|
||||
"existing library ever gets the bass tuning")
|
||||
|
||||
row = scan.appstate.meta_db.conn.execute(
|
||||
"SELECT bass_tuning_name, bass_tuning_offsets FROM songs "
|
||||
"WHERE filename = 'song.feedpak'").fetchone()
|
||||
assert row == ("Drop D", "-2 0 0 0 0 0")
|
||||
|
||||
# Converged: the fast path is back and nothing re-extracts.
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert seen == []
|
||||
|
||||
|
||||
# ── 5. The facet endpoint ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def facet_seeded(server_mod):
|
||||
"""Three shapes, matching the real library's distribution:
|
||||
differ — guitar D Standard, bass E Standard (the bug)
|
||||
match — both Drop D (common)
|
||||
nobass — guitar Drop D, no bass chart (fallback, common)
|
||||
"""
|
||||
_put(server_mod, filename="differ.sloppak", title="Differ",
|
||||
tuning_name_="D Standard", tuning_sort_key=-12,
|
||||
tuning_offsets="-2 -2 -2 -2 -2 -2",
|
||||
bass_tuning_name="E Standard", bass_tuning_sort_key=0,
|
||||
bass_tuning_offsets="0 0 0 0 0 0")
|
||||
_put(server_mod, filename="match.sloppak", title="Match",
|
||||
tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0",
|
||||
bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
|
||||
bass_tuning_offsets="-2 0 0 0 0 0")
|
||||
_put(server_mod, filename="nobass.sloppak", title="NoBass",
|
||||
tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0")
|
||||
|
||||
|
||||
def _facet(client, **kw):
|
||||
return {t["name"]: t["count"]
|
||||
for t in client.get("/api/library/tuning-names", params=kw).json()["tunings"]}
|
||||
|
||||
|
||||
def test_facet_defaults_to_the_guitar_tuning(client, facet_seeded):
|
||||
assert _facet(client) == {"D Standard": 1, "Drop D": 2}
|
||||
|
||||
|
||||
def test_facet_bass_groups_by_bass_tuning_with_guitar_fallback(client, facet_seeded):
|
||||
"""differ counts under its BASS tuning (E Standard), match under Drop D,
|
||||
and nobass — having no bass chart — falls back to its guitar Drop D rather
|
||||
than vanishing from the facet."""
|
||||
assert _facet(client, instrument="bass") == {"E Standard": 1, "Drop D": 2}
|
||||
|
||||
|
||||
def test_facet_ignores_an_unknown_instrument(client, facet_seeded):
|
||||
"""An unknown value must not silently change filter semantics."""
|
||||
assert _facet(client, instrument="theremin") == _facet(client)
|
||||
|
||||
|
||||
# ── 6. The filter: the actual reported bug ───────────────────────────────────
|
||||
|
||||
def _files(client, **kw):
|
||||
return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
|
||||
|
||||
|
||||
def test_bass_filter_excludes_a_song_whose_only_match_is_its_guitar_tuning(
|
||||
client, facet_seeded):
|
||||
"""THE BUG. Filtering bass "D Standard" must NOT return `differ` — its
|
||||
D Standard is the GUITAR chart; its bass is in E Standard."""
|
||||
assert _files(client, tunings="D Standard") == {"differ.sloppak"}
|
||||
assert _files(client, tunings="D Standard", instrument="bass") == set()
|
||||
|
||||
|
||||
def test_bass_filter_returns_songs_by_their_bass_tuning(client, facet_seeded):
|
||||
"""…and the converse: bass "E Standard" finds `differ`, which the guitar
|
||||
filter would never return."""
|
||||
assert _files(client, tunings="E Standard") == set()
|
||||
assert _files(client, tunings="E Standard", instrument="bass") == {"differ.sloppak"}
|
||||
|
||||
|
||||
def test_bass_filter_keeps_songs_without_a_bass_arrangement_via_fallback(
|
||||
client, facet_seeded):
|
||||
"""The most common shape. `nobass` has no bass chart, so it must still be
|
||||
reachable under its guitar tuning instead of disappearing for bass users —
|
||||
and the facet's count for that pill must equal what the filter returns."""
|
||||
got = _files(client, tunings="Drop D", instrument="bass")
|
||||
assert got == {"match.sloppak", "nobass.sloppak"}
|
||||
assert _facet(client, instrument="bass")["Drop D"] == len(got)
|
||||
|
||||
|
||||
def test_custom_bass_tunings_stay_distinct_under_their_offsets(client, server_mod):
|
||||
"""Two unnameable bass tunings both label "Custom Tuning"; the facet keys
|
||||
them on raw offsets so selecting one doesn't drag in the other. Uses the
|
||||
real [5,5,5,5,4,4] shape from the library."""
|
||||
_put(server_mod, filename="c1.sloppak", title="C1",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
|
||||
bass_tuning_offsets="5 5 5 5 4 4")
|
||||
_put(server_mod, filename="c2.sloppak", title="C2",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-7,
|
||||
bass_tuning_offsets="-3 -1 -1 -1 -1 0")
|
||||
keys = [t["key"] for t in client.get(
|
||||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
|
||||
if t["name"] == "Custom Tuning"]
|
||||
assert sorted(keys) == sorted(["5 5 5 5 4 4", "-3 -1 -1 -1 -1 0"])
|
||||
assert _files(client, tunings="5 5 5 5 4 4", instrument="bass") == {"c1.sloppak"}
|
||||
|
||||
|
||||
def test_stats_facet_counts_agree_with_the_bass_filter(client, facet_seeded):
|
||||
"""The A–Z rail / count surface must apply the same instrument-aware
|
||||
predicate as the grid, or the header count contradicts the results."""
|
||||
body = client.get("/api/library/stats", params={
|
||||
"tunings": "Drop D", "instrument": "bass"}).json()
|
||||
assert body["total_songs"] == 2
|
||||
|
||||
|
||||
# ── 7. Sort ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_tuning_sort_respects_the_instrument(client, facet_seeded):
|
||||
"""Tuning sort is musical distance from E Standard. For a bass player that
|
||||
distance must be measured on the BASS tuning: `differ` is the furthest
|
||||
song by guitar (D Standard, |−12|) but the nearest by bass (E Standard, 0),
|
||||
so it moves from last to first."""
|
||||
def order(**kw):
|
||||
return [s["filename"] for s in client.get(
|
||||
"/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
|
||||
|
||||
guitar = order()
|
||||
assert guitar[-1] == "differ.sloppak"
|
||||
bass = order(instrument="bass")
|
||||
assert bass[0] == "differ.sloppak"
|
||||
|
||||
|
||||
# ── 8. Song payload ──────────────────────────────────────────────────────────
|
||||
|
||||
# ── 9. Real-library offset SHAPES ────────────────────────────────────────────
|
||||
# Measured across the 59-pack test library: bass offset lists are NOT reliably
|
||||
# 4 or reliably 6 — 41 store six elements, 1 stores four. Two six-element ones
|
||||
# diverge in the tail (AC/DC "Girls Got Rhythm" [5,5,5,5,4,4]; Intervals
|
||||
# "Libra" [-2,0,0,0,0,0]). Nothing may crash or mislabel on any of them.
|
||||
|
||||
@pytest.mark.parametrize("offsets,expected", [
|
||||
([0, 0, 0, 0], "E Standard"), # four-element (the 1 outlier)
|
||||
([0, 0, 0, 0, 0, 0], "E Standard"), # six-element all-equal (39 of them)
|
||||
([-1, -1, -1, -1], "Eb Standard"), # four-element, down a semitone
|
||||
([5, 5, 5, 5, 4, 4], "Custom Tuning"), # AC/DC — divergent tail
|
||||
([-2, 0, 0, 0, 0, 0], "Drop D"), # Intervals — drop + trailing zeros
|
||||
([0, 0, 0, 0, 0], "Custom Tuning"), # five: no naming convention → custom
|
||||
])
|
||||
def test_real_library_bass_offset_shapes_name_without_crashing(offsets, expected):
|
||||
assert tuning_name(offsets) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offsets", [
|
||||
[0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [5, 5, 5, 5, 4, 4], [-2, 0, 0, 0, 0, 0],
|
||||
])
|
||||
def test_real_library_bass_offset_shapes_survive_extraction(tmp_path, offsets):
|
||||
"""Each shape must round-trip the real extractor + scanner derivation,
|
||||
landing on the NORMALIZED (truncated, plausibility-checked) columns."""
|
||||
norm = normalize_bass_offsets(offsets)
|
||||
d = _pack(tmp_path, "shape.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Bass", "tuning": offsets},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["bass_tuning_name"] == bass_tuning_name(norm)
|
||||
assert meta["bass_tuning_offsets"] == " ".join(str(o) for o in norm)
|
||||
assert meta["bass_tuning_sort_key"] == sum(norm)
|
||||
assert meta["bass_tuning_key"] == bass_tuning_key(norm)
|
||||
|
||||
|
||||
def test_named_bass_tunings_group_across_serialization_lengths(client, server_mod):
|
||||
"""The length question does NOT fragment NAMED tunings: a bass stored as
|
||||
four elements and one stored as six both name "E Standard", and the facet
|
||||
groups by name — so they land in ONE row with a combined count. This is the
|
||||
common case (40 of the 42 bass arrangements in the real library)."""
|
||||
_put(server_mod, filename="four.sloppak", title="Four",
|
||||
bass_tuning_name=tuning_name([0, 0, 0, 0]), bass_tuning_offsets="0 0 0 0")
|
||||
_put(server_mod, filename="six.sloppak", title="Six",
|
||||
bass_tuning_name=tuning_name([0, 0, 0, 0, 0, 0]),
|
||||
bass_tuning_offsets="0 0 0 0 0 0")
|
||||
assert _facet(client, instrument="bass") == {"E Standard": 2}
|
||||
assert _files(client, tunings="E Standard", instrument="bass") == {
|
||||
"four.sloppak", "six.sloppak"}
|
||||
|
||||
|
||||
def test_drop_d_bass_groups_across_serialization_lengths(client, server_mod):
|
||||
"""Same for the Intervals shape: [-2,0,0,0,0,0] and [-2,0,0,0] both name
|
||||
"Drop D", so trailing zeros can't split a named tuning into two rows."""
|
||||
_put(server_mod, filename="d6.sloppak", title="D6",
|
||||
bass_tuning_name=tuning_name([-2, 0, 0, 0, 0, 0]),
|
||||
bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0 0 0")
|
||||
_put(server_mod, filename="d4.sloppak", title="D4",
|
||||
bass_tuning_name=tuning_name([-2, 0, 0, 0]),
|
||||
bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0")
|
||||
assert _facet(client, instrument="bass") == {"Drop D": 2}
|
||||
|
||||
|
||||
def test_equivalent_custom_bass_tunings_group_into_one_facet_row(client, server_mod):
|
||||
"""Two CUSTOM bass tunings that are the same physical tuning must be ONE
|
||||
facet row, however they were serialized. They group on canonical PITCHES
|
||||
(bass_tuning_key), so the offsets string no longer fragments them —
|
||||
previously this produced two rows with split counts."""
|
||||
key = bass_tuning_key([-3, -1, -1, -1])
|
||||
_put(server_mod, filename="c6.sloppak", title="C6",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
|
||||
bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
|
||||
_put(server_mod, filename="c4.sloppak", title="C4",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
|
||||
bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
|
||||
rows = client.get("/api/library/tuning-names",
|
||||
params={"instrument": "bass"}).json()["tunings"]
|
||||
customs = [t for t in rows if t["name"] == "Custom Tuning"]
|
||||
assert len(customs) == 1 and customs[0]["count"] == 2
|
||||
assert _files(client, tunings=customs[0]["key"], instrument="bass") == {
|
||||
"c6.sloppak", "c4.sloppak"}
|
||||
|
||||
|
||||
def test_canonical_key_is_pitch_not_serialization(tmp_path):
|
||||
"""The property that makes the grouping robust: two serializations of one
|
||||
tuning yield the same key, and two genuinely different tunings do not."""
|
||||
assert bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0, 0, 0])) == \
|
||||
bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0]))
|
||||
assert bass_tuning_key([-2, 0, 0, 0]) != bass_tuning_key([-3, 0, 0, 0])
|
||||
# Absolute open pitches of a standard 4-string bass (E1 A1 D2 G2).
|
||||
assert bass_tuning_key([0, 0, 0, 0]) == "bass:28:33:38:43"
|
||||
|
||||
|
||||
def test_custom_bass_facet_row_selects_exactly_what_it_counted(client, server_mod):
|
||||
"""Whatever the grouping rule, the invariant that must NEVER break: every
|
||||
facet row's count equals the number of songs its own key returns. This is
|
||||
what makes the seam safe to change — a normalization that merged rows but
|
||||
not the filter would fail here."""
|
||||
_put(server_mod, filename="x6.sloppak", title="X6",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
|
||||
bass_tuning_offsets="5 5 5 5 4 4")
|
||||
_put(server_mod, filename="x4.sloppak", title="X4",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=20,
|
||||
bass_tuning_offsets="5 5 5 5")
|
||||
_put(server_mod, filename="plain.sloppak", title="Plain",
|
||||
bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
|
||||
for row in client.get("/api/library/tuning-names",
|
||||
params={"instrument": "bass"}).json()["tunings"]:
|
||||
got = _files(client, tunings=row["key"], instrument="bass")
|
||||
assert len(got) == row["count"], (
|
||||
f"facet row {row['key']!r} counted {row['count']} but selects {len(got)}")
|
||||
|
||||
|
||||
# ── 10. THE HEADLINE REGRESSION ──────────────────────────────────────────────
|
||||
|
||||
def test_covet_shibuya_is_findable_by_a_bassist(tmp_path, server_mod, client):
|
||||
"""Covet - "Shibuya" (Effloresce): the guitar is in a custom tuning
|
||||
[-2,0,0,-1,-2,0] while the bass is dead standard. This is the tester's bug
|
||||
in one song — a bassist filtering "E Standard" never saw it, because the
|
||||
library only knew the guitar's custom tuning.
|
||||
|
||||
Round-tripped through the REAL extractor and scanner derivation, not
|
||||
hand-written columns, so it covers the whole chain."""
|
||||
d = _pack(tmp_path, "shibuya.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
|
||||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
server_mod.meta_db.put("shibuya.sloppak", 1.0, 1, {
|
||||
**meta, "title": "Shibuya", "artist": "Covet", "album": "Effloresce"})
|
||||
|
||||
# The guitar chart really is a custom tuning…
|
||||
assert meta["tuning_name"] == "Custom Tuning"
|
||||
# …and the bass chart really is standard.
|
||||
assert meta["bass_tuning_name"] == "E Standard"
|
||||
|
||||
# Before the fix a bassist filtering E Standard got nothing.
|
||||
assert _files(client, tunings="E Standard") == set()
|
||||
assert _files(client, tunings="E Standard", instrument="bass") == {"shibuya.sloppak"}
|
||||
|
||||
# And it appears in the bass facet under E Standard, as a REAL bass chart
|
||||
# (not an inferred fallback).
|
||||
row = next(t for t in client.get(
|
||||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
|
||||
if t["name"] == "E Standard")
|
||||
assert row["count"] == 1 and row["inferred_count"] == 0
|
||||
|
||||
|
||||
# ── 11. Provenance: the fallback must be honest, never silent ────────────────
|
||||
|
||||
def test_facet_reports_how_many_rows_are_inferred_from_the_guitar_chart(
|
||||
client, facet_seeded):
|
||||
"""The fallback keeps no-bass-chart songs visible (a third of a real
|
||||
library), but the UI must be able to say so. `nobass` has no bass chart and
|
||||
rides under the guitar's Drop D; `match` has a real one."""
|
||||
rows = {t["name"]: t for t in client.get(
|
||||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]}
|
||||
assert rows["Drop D"]["count"] == 2
|
||||
assert rows["Drop D"]["inferred_count"] == 1 # nobass only
|
||||
assert rows["E Standard"]["inferred_count"] == 0 # differ has a real bass chart
|
||||
|
||||
|
||||
def test_guitar_facet_reports_no_inferred_rows(client, facet_seeded):
|
||||
"""Guitar is never a fallback perspective, so nothing is ever inferred."""
|
||||
rows = client.get("/api/library/tuning-names").json()["tunings"]
|
||||
assert all(t["inferred_count"] == 0 for t in rows)
|
||||
|
||||
|
||||
def test_song_rows_mark_an_inferred_tuning(client, facet_seeded):
|
||||
"""A bass player's row must be distinguishable: native bass chart vs
|
||||
borrowed from the guitar. Without this the card silently presents a guitar
|
||||
tuning as the bass tuning — the original bug in a new place."""
|
||||
rows = {s["filename"]: s for s in client.get(
|
||||
"/api/library", params={"instrument": "bass"}).json()["songs"]}
|
||||
assert rows["differ.sloppak"]["tuning_inferred"] is False
|
||||
assert rows["nobass.sloppak"]["tuning_inferred"] is True
|
||||
assert rows["differ.sloppak"]["tuning_perspective"] == "bass"
|
||||
|
||||
|
||||
def test_guitar_rows_carry_no_bass_perspective_fields(client, facet_seeded):
|
||||
"""The guitar payload is untouched — no perspective/inferred keys at all."""
|
||||
row = client.get("/api/library").json()["songs"][0]
|
||||
assert "tuning_inferred" not in row and "tuning_perspective" not in row
|
||||
|
||||
|
||||
def test_arrangements_has_bass_is_the_real_bass_chart_lever(server_mod, client):
|
||||
"""'Only songs with a real bass chart' is the EXISTING `arrangements_has`
|
||||
filter — no new filter, no "confirmed tunings" checkbox. It composes with
|
||||
the tuning filter, so a bassist who wants to exclude inferred rows already
|
||||
can, and it is already expressible in a saved collection rule."""
|
||||
def put_with_arrs(fn, arrs, **kw):
|
||||
server_mod.meta_db.put(fn, 1.0, 1, {
|
||||
"title": fn, "artist": "A", "album": "A - LP", "year": "2010",
|
||||
"duration": 200.0, "tuning": "Drop D", "arrangements": arrs,
|
||||
"has_lyrics": False, "format": "sloppak", "stem_ids": [],
|
||||
"tuning_name": "Drop D", "tuning_sort_key": -2,
|
||||
"tuning_offsets": "-2 0 0 0 0 0", **kw})
|
||||
|
||||
put_with_arrs("withbass.sloppak",
|
||||
[{"index": 0, "name": "Lead"}, {"index": 1, "name": "Bass"}],
|
||||
bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
|
||||
bass_tuning_offsets="-2 0 0 0",
|
||||
bass_tuning_key=bass_tuning_key([-2, 0, 0, 0]))
|
||||
put_with_arrs("nobass.sloppak", [{"index": 0, "name": "Lead"}])
|
||||
|
||||
# Both are reachable under the bass Drop D pill (the fallback keeps the
|
||||
# no-bass-chart song visible)…
|
||||
assert _files(client, tunings="Drop D", instrument="bass") == {
|
||||
"withbass.sloppak", "nobass.sloppak"}
|
||||
# …and the existing arrangements_has lever narrows to real bass charts.
|
||||
assert _files(client, tunings="Drop D", instrument="bass",
|
||||
arrangements_has="Bass") == {"withbass.sloppak"}
|
||||
|
||||
|
||||
def test_song_rows_carry_the_bass_tuning_for_the_client(client, facet_seeded):
|
||||
"""The card renders the bass tuning client-side, so the row must ship it —
|
||||
and ship '' (not the guitar value) when there is no bass chart, so the
|
||||
client's fallback stays the client's decision."""
|
||||
rows = {s["filename"]: s for s in client.get("/api/library").json()["songs"]}
|
||||
assert rows["differ.sloppak"]["tuning_name"] == "D Standard"
|
||||
assert rows["differ.sloppak"]["bass_tuning_name"] == "E Standard"
|
||||
assert rows["differ.sloppak"]["bass_tuning_offsets"] == "0 0 0 0 0 0"
|
||||
assert rows["nobass.sloppak"]["bass_tuning_name"] == ""
|
||||
@@ -1,294 +0,0 @@
|
||||
"""The three-valued tuning PERSPECTIVE, and "playable without retuning".
|
||||
|
||||
Two behaviours that extend the bass tuning fix (see
|
||||
test_library_tuning_instrument.py):
|
||||
|
||||
1. `active_instrument_profile` has three values (guitar-lead / guitar-rhythm /
|
||||
bass), so the tuning perspective must too. Lead and rhythm charts can be
|
||||
tuned differently, which is the identical bug a bassist hit, inside guitar.
|
||||
|
||||
2. Exact tuning match answers "which tuning is this labelled". A player
|
||||
actually wants "will this cost me a retune". Both are offered; exact stays
|
||||
the default.
|
||||
|
||||
Everything round-trips through the real extractor, the real scanner
|
||||
derivation, the real schema and the real HTTP surface.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from scan_worker import _extract_meta_for_file
|
||||
from tunings import (
|
||||
PERSPECTIVES, bass_tuning_key, chart_is_playable_in, perspective_tuning_key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _pack(root, name, arrangements):
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(yaml.safe_dump({
|
||||
"title": name, "artist": "A", "duration": 100,
|
||||
"arrangements": arrangements, "stems": [],
|
||||
}), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _files(client, **kw):
|
||||
return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
|
||||
|
||||
|
||||
# ── 1. The same bug WITHIN guitar: lead vs rhythm ────────────────────────────
|
||||
|
||||
def test_rhythm_chart_tuning_is_indexed_separately(tmp_path):
|
||||
"""A song whose LEAD is in E standard but whose RHYTHM is in Drop D must
|
||||
index both — through the real extractor + scanner derivation."""
|
||||
d = _pack(tmp_path, "split.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Rhythm", "tuning": [-2, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["tuning_name"] == "E Standard" # song-level = guitar-first
|
||||
assert meta["rhythm_tuning_name"] == "Drop D" # the rhythm chart's own
|
||||
assert meta["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
assert meta["rhythm_tuning_low_pitch"] == 38 # low D
|
||||
|
||||
|
||||
def test_rhythm_offsets_are_not_truncated(tmp_path):
|
||||
"""Only BASS truncates (its arrays are padded). A 7-string guitar array is
|
||||
real data — cutting it to 6 would invent a tuning the chart doesn't have."""
|
||||
d = _pack(tmp_path, "seven.sloppak", [
|
||||
{"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2, -2]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["rhythm_tuning_offsets"] == "-2 -2 -2 -2 -2 -2 -2"
|
||||
|
||||
|
||||
def test_no_rhythm_arrangement_leaves_the_columns_empty(tmp_path):
|
||||
d = _pack(tmp_path, "leadonly.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["rhythm_tuning_name"] == ""
|
||||
assert meta["rhythm_tuning_key"] == ""
|
||||
|
||||
|
||||
def _put(server_mod, fn, **kw):
|
||||
base = dict(title=fn, artist="A", album="LP", year="2010", duration=200.0,
|
||||
tuning="E Standard", arrangements=[], has_lyrics=False,
|
||||
format="sloppak", stem_ids=[], tuning_name="E Standard",
|
||||
tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
|
||||
tuning_low_pitch=40)
|
||||
base.update(kw)
|
||||
server_mod.meta_db.put(fn, 1.0, 1, base)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rhythm_seeded(server_mod):
|
||||
"""Both songs are E Standard by LEAD. One has a Drop D rhythm chart; the
|
||||
other has no rhythm chart at all (so it falls back + is marked inferred)."""
|
||||
_put(server_mod, "rdiffer.sloppak",
|
||||
rhythm_tuning_name="Drop D", rhythm_tuning_sort_key=-2,
|
||||
rhythm_tuning_offsets="-2 0 0 0 0 0",
|
||||
rhythm_tuning_key=perspective_tuning_key(
|
||||
[-2, 0, 0, 0, 0, 0], PERSPECTIVES["guitar-rhythm"]),
|
||||
rhythm_tuning_low_pitch=38)
|
||||
_put(server_mod, "rnone.sloppak")
|
||||
|
||||
|
||||
def test_rhythm_filter_excludes_a_lead_only_tuning_match(client, rhythm_seeded):
|
||||
"""THE WITHIN-GUITAR BUG. Filtering rhythm "E Standard" must not return
|
||||
rdiffer — that is its LEAD tuning; its rhythm chart is in Drop D."""
|
||||
assert _files(client, tunings="E Standard") == {"rdiffer.sloppak", "rnone.sloppak"}
|
||||
# rnone has no rhythm chart, so it falls back to its lead tuning and stays.
|
||||
assert _files(client, tunings="E Standard", instrument="guitar-rhythm") == {
|
||||
"rnone.sloppak"}
|
||||
assert _files(client, tunings="Drop D", instrument="guitar-rhythm") == {
|
||||
"rdiffer.sloppak"}
|
||||
# …and Drop D finds nothing from the lead perspective.
|
||||
assert _files(client, tunings="Drop D") == set()
|
||||
|
||||
|
||||
def test_rhythm_perspective_marks_inferred_rows(client, rhythm_seeded):
|
||||
rows = {s["filename"]: s for s in client.get(
|
||||
"/api/library", params={"instrument": "guitar-rhythm"}).json()["songs"]}
|
||||
assert rows["rdiffer.sloppak"]["tuning_inferred"] is False
|
||||
assert rows["rnone.sloppak"]["tuning_inferred"] is True
|
||||
assert rows["rdiffer.sloppak"]["tuning_perspective"] == "guitar-rhythm"
|
||||
|
||||
|
||||
def test_rhythm_facet_reports_inferred_portion(client, rhythm_seeded):
|
||||
rows = {t["name"]: t for t in client.get(
|
||||
"/api/library/tuning-names",
|
||||
params={"instrument": "guitar-rhythm"}).json()["tunings"]}
|
||||
assert rows["Drop D"]["count"] == 1 and rows["Drop D"]["inferred_count"] == 0
|
||||
assert rows["E Standard"]["count"] == 1 and rows["E Standard"]["inferred_count"] == 1
|
||||
|
||||
|
||||
def test_facet_row_selects_exactly_what_it_counted_for_rhythm(client, rhythm_seeded):
|
||||
"""The invariant that must hold for EVERY perspective."""
|
||||
for row in client.get("/api/library/tuning-names",
|
||||
params={"instrument": "guitar-rhythm"}).json()["tunings"]:
|
||||
got = _files(client, tunings=row["key"], instrument="guitar-rhythm")
|
||||
assert len(got) == row["count"], row["key"]
|
||||
|
||||
|
||||
def test_guitar_lead_is_byte_identical_to_the_legacy_default(client, rhythm_seeded):
|
||||
"""The majority path must not regress: the default payload gains no keys,
|
||||
and the legacy two-valued vocabulary still resolves to it."""
|
||||
default = client.get("/api/library").json()
|
||||
explicit = client.get("/api/library", params={"instrument": "guitar-lead"}).json()
|
||||
legacy = client.get("/api/library", params={"instrument": "guitar"}).json()
|
||||
assert default == explicit == legacy
|
||||
row = default["songs"][0]
|
||||
assert "tuning_inferred" not in row and "tuning_perspective" not in row
|
||||
|
||||
|
||||
def test_unknown_perspective_falls_back_to_lead(client, rhythm_seeded):
|
||||
"""An unrecognised value must never silently change filter semantics."""
|
||||
assert client.get("/api/library", params={"instrument": "kazoo"}).json() == \
|
||||
client.get("/api/library").json()
|
||||
|
||||
|
||||
def test_tuning_sort_respects_the_rhythm_perspective(client, rhythm_seeded):
|
||||
"""Sort is musical distance from standard. rdiffer is 0 away by lead but
|
||||
-2 by rhythm, so the perspective changes its position."""
|
||||
def order(**kw):
|
||||
return [s["filename"] for s in client.get(
|
||||
"/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
|
||||
assert order()[0] == "rdiffer.sloppak" # tie → filename
|
||||
assert order(instrument="guitar-rhythm")[0] == "rnone.sloppak" # 0 beats -2
|
||||
|
||||
|
||||
# ── 2. "Playable without retuning" ───────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("your_low,chart_low,expected", [
|
||||
(23, 28, True), # 5-string bass (low B) plays a 4-string standard chart
|
||||
(23, 26, True), # …and a drop-D chart: the low D is fretted on the B string
|
||||
(28, 26, False), # 4-string standard CANNOT reach a drop-D chart's low D
|
||||
(28, 28, True), # identical tuning
|
||||
(40, 38, False), # guitar standard vs a drop-D chart
|
||||
(38, 40, True), # a drop-D guitar covers a standard chart
|
||||
(None, 28, False), # unknown chart pitch is never claimed playable
|
||||
(28, None, False),
|
||||
])
|
||||
def test_playability_rule(your_low, chart_low, expected):
|
||||
"""The core comparison as a property: your lowest open string vs the
|
||||
chart's lowest required pitch. Unknown => not playable (conservative)."""
|
||||
assert chart_is_playable_in(chart_low, your_low) is expected
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pitched(server_mod):
|
||||
_put(server_mod, "std.sloppak", tuning_low_pitch=40)
|
||||
_put(server_mod, "dropd.sloppak", tuning="Drop D", tuning_name="Drop D",
|
||||
tuning_offsets="-2 0 0 0 0 0", tuning_sort_key=-2, tuning_low_pitch=38)
|
||||
_put(server_mod, "dropc.sloppak", tuning="Drop C", tuning_name="Drop C",
|
||||
tuning_offsets="-4 -2 -2 -2 -2 -2", tuning_sort_key=-14, tuning_low_pitch=36)
|
||||
|
||||
|
||||
def _playable(client, offsets, instrument="guitar", sc=6, **kw):
|
||||
return {s["filename"] for s in client.get("/api/library", params={
|
||||
"tuning_match": "playable", "playable_offsets": offsets,
|
||||
"playable_instrument": instrument, "playable_string_count": str(sc), **kw,
|
||||
}).json()["songs"]}
|
||||
|
||||
|
||||
def test_playable_from_standard_excludes_lower_tuned_charts(client, pitched):
|
||||
"""In E standard you can play the standard chart, but the drop-D and
|
||||
drop-C charts need a retune — exactly what the tester wants surfaced."""
|
||||
assert _playable(client, "0,0,0,0,0,0") == {"std.sloppak"}
|
||||
|
||||
|
||||
def test_playable_from_drop_c_covers_everything_above_it(client, pitched):
|
||||
"""Tuned DOWN to drop C, every higher-tuned chart is reachable by fretting
|
||||
— the dominant real case this feature exists for."""
|
||||
assert _playable(client, "-4,-2,-2,-2,-2,-2") == {
|
||||
"std.sloppak", "dropd.sloppak", "dropc.sloppak"}
|
||||
|
||||
|
||||
def test_playable_is_a_mode_not_a_replacement_for_exact(client, pitched):
|
||||
"""Exact match still works untouched, and returns something DIFFERENT from
|
||||
playable — they answer different questions."""
|
||||
exact = {s["filename"] for s in client.get(
|
||||
"/api/library", params={"tunings": "Drop D"}).json()["songs"]}
|
||||
assert exact == {"dropd.sloppak"}
|
||||
assert _playable(client, "-2,0,0,0,0,0") == {"std.sloppak", "dropd.sloppak"}
|
||||
|
||||
|
||||
def test_playable_excludes_rows_with_no_indexed_pitch(client, server_mod, pitched):
|
||||
"""Conservative by construction: a chart whose low pitch we could not
|
||||
compute is EXCLUDED, never assumed playable. Wrongly claiming playability
|
||||
costs a mid-practice retune — the failure this feature prevents."""
|
||||
_put(server_mod, "unknown.sloppak", tuning_low_pitch=None)
|
||||
assert "unknown.sloppak" not in _playable(client, "-4,-2,-2,-2,-2,-2")
|
||||
# …but it is still reachable normally, so it isn't lost from the library.
|
||||
assert any(s["filename"] == "unknown.sloppak"
|
||||
for s in client.get("/api/library").json()["songs"])
|
||||
|
||||
|
||||
def test_malformed_playable_tuning_applies_no_filter(client, pitched):
|
||||
"""A tuning we cannot resolve must not silently claim everything is
|
||||
playable OR that nothing is — it applies no filter at all."""
|
||||
everything = {s["filename"] for s in client.get("/api/library").json()["songs"]}
|
||||
assert _playable(client, "not,a,tuning") == everything
|
||||
assert _playable(client, "") == everything
|
||||
# A string count that disagrees with the offsets is equally unusable.
|
||||
assert _playable(client, "0,0,0,0", instrument="guitar", sc=6) == everything
|
||||
|
||||
|
||||
def test_playable_respects_the_bass_perspective(client, server_mod):
|
||||
"""A 5-string bass (low B) can play a 4-string standard bass chart. The
|
||||
comparison must run on the BASS tuning — this song's GUITAR chart is tuned
|
||||
far lower, so reading the wrong column would flip the answer."""
|
||||
_put(server_mod, "bassy.sloppak",
|
||||
tuning="Custom Tuning", tuning_name="Custom Tuning",
|
||||
tuning_offsets="-4 -2 -2 -1 -2 0", tuning_sort_key=-11,
|
||||
tuning_low_pitch=36,
|
||||
bass_tuning_name="E Standard", bass_tuning_sort_key=0,
|
||||
bass_tuning_offsets="0 0 0 0",
|
||||
bass_tuning_key=bass_tuning_key([0, 0, 0, 0]),
|
||||
bass_tuning_low_pitch=28)
|
||||
# 5-string bass low B (23) <= the chart low E (28) → playable.
|
||||
got = {s["filename"] for s in client.get("/api/library", params={
|
||||
"tuning_match": "playable", "playable_offsets": "0,0,0,0,0",
|
||||
"playable_instrument": "bass", "playable_string_count": "5",
|
||||
"instrument": "bass"}).json()["songs"]}
|
||||
assert got == {"bassy.sloppak"}
|
||||
# A 4-string bass tuned UP a semitone (low F, 29) cannot reach the low E.
|
||||
got_up = {s["filename"] for s in client.get("/api/library", params={
|
||||
"tuning_match": "playable", "playable_offsets": "1,1,1,1",
|
||||
"playable_instrument": "bass", "playable_string_count": "4",
|
||||
"instrument": "bass"}).json()["songs"]}
|
||||
assert got_up == set()
|
||||
|
||||
|
||||
def test_playable_and_stats_agree(client, pitched):
|
||||
"""The count surface must apply the same predicate as the grid."""
|
||||
body = client.get("/api/library/stats", params={
|
||||
"tuning_match": "playable", "playable_offsets": "0,0,0,0,0,0",
|
||||
"playable_instrument": "guitar", "playable_string_count": "6"}).json()
|
||||
assert body["total_songs"] == 1
|
||||
@@ -302,44 +302,3 @@ def test_extract_meta_uses_lead_tuning_when_bass_sorts_first(tmp_path):
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
# …and the bass chart's OWN tuning is indexed alongside it, so a bass
|
||||
# player's library filter isn't answered with the guitar tuning.
|
||||
assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, 0, 0]
|
||||
|
||||
|
||||
def test_extract_meta_bass_tuning_absent_without_bass_arrangement(tmp_path):
|
||||
"""A folder with no bass chart leaves the bass tuning EMPTY (None) rather
|
||||
than echoing the guitar tuning — the library then falls back explicitly,
|
||||
and 'no bass part' stays distinguishable from 'bass part in E Standard'."""
|
||||
(tmp_path / "audio.wem").write_bytes(b"\0")
|
||||
(tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
assert meta["bass_tuning_offsets"] is None
|
||||
|
||||
|
||||
def test_extract_meta_bass_tuning_matches_guitar_is_still_indexed(tmp_path):
|
||||
"""The COMMON case: bass and guitar in the same tuning. The bass column
|
||||
must still be populated — an empty one would be read as 'no bass chart'."""
|
||||
(tmp_path / "audio.wem").write_bytes(b"\0")
|
||||
_write_min_xml(tmp_path / "lead.xml", arrangement="Lead")
|
||||
_write_min_xml(tmp_path / "bass.xml", arrangement="Bass")
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_extract_meta_manifest_tuning_does_not_become_the_bass_tuning(tmp_path):
|
||||
"""A manifest `tuning_offsets` overrides the SONG tuning but says nothing
|
||||
about which chart it describes, so it must never be mistaken for the bass
|
||||
part's tuning — with no bass chart the bass column stays empty."""
|
||||
(tmp_path / "audio.wem").write_bytes(b"\0")
|
||||
(tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")
|
||||
(tmp_path / "manifest.json").write_text(json.dumps({
|
||||
"tuning_offsets": [-2, -2, -2, -2, -2, -2],
|
||||
}), encoding="utf-8")
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
|
||||
assert meta["bass_tuning_offsets"] is None
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"""Tests for lib/midi_import.py — extract_midi_lyrics (lyrics + vocal melody).
|
||||
|
||||
Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path,
|
||||
same style as test_midi_import.py.
|
||||
|
||||
Covers:
|
||||
- Lyric (0x05) events on a vocal track → lyrics.json + vocal_pitch.json payloads
|
||||
- lyric/note pairing snaps t/d to the note; midi pitch carried through
|
||||
- lyrics with no identifiable vocal track → lyrics payload only
|
||||
- no lyric events at all → None (import behavior unchanged)
|
||||
- .kar '/' line-break prefixes → spec trailing '+' on the previous syllable
|
||||
- .kar '-' hyphen joins pass through untouched
|
||||
- space-delimited syllable streams gain '-' joins
|
||||
- '@'-metadata tokens dropped; Text-event (0x01) fallback on vocal-ish tracks
|
||||
- Text events on non-vocal tracks are NOT treated as lyrics
|
||||
- unpaired lyric durations run to the next syllable, capped at 2.0 s
|
||||
- format-0 mixed-channel file pairs only the vocal-program channel
|
||||
- vocal GM program (52-54 / 85-87) detection without a track name
|
||||
- audio_offset applied to both payloads
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import mido
|
||||
|
||||
from midi_import import extract_midi_lyrics
|
||||
|
||||
|
||||
TPB = 480 # ticks per beat; default 120 BPM → 480 ticks = 0.5 s
|
||||
|
||||
|
||||
def _save(mid: mido.MidiFile, tmp_path, name: str = "test.mid") -> str:
|
||||
p = tmp_path / name
|
||||
mid.save(str(p))
|
||||
return str(p)
|
||||
|
||||
|
||||
def _vocal_file(tmp_path, *, track_name="Vocals", program=None, meta="lyrics",
|
||||
syllables=("Hel-", "lo", "world")):
|
||||
"""Type-1 file: conductor + one melody track carrying notes with a lyric
|
||||
event at each note-on. Notes: 60, 62, 64, each one beat (0.5 s) long,
|
||||
back to back."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
||||
conductor = mido.MidiTrack()
|
||||
mid.tracks.append(conductor)
|
||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
|
||||
tr = mido.MidiTrack()
|
||||
mid.tracks.append(tr)
|
||||
if track_name:
|
||||
tr.append(mido.MetaMessage("track_name", name=track_name, time=0))
|
||||
if program is not None:
|
||||
tr.append(mido.Message("program_change", channel=0, program=program, time=0))
|
||||
for i, syl in enumerate(syllables):
|
||||
tr.append(mido.MetaMessage(meta, text=syl, time=0))
|
||||
tr.append(mido.Message("note_on", channel=0, note=60 + 2 * i,
|
||||
velocity=90, time=0))
|
||||
tr.append(mido.Message("note_off", channel=0, note=60 + 2 * i,
|
||||
velocity=0, time=TPB))
|
||||
return _save(mid, tmp_path)
|
||||
|
||||
|
||||
# ── both sidecars from a lyric+vocal-track file ──────────────────────────────
|
||||
|
||||
def test_vocal_track_emits_both_payloads(tmp_path):
|
||||
result = extract_midi_lyrics(_vocal_file(tmp_path))
|
||||
assert result is not None
|
||||
assert result["lyrics_source"] == "authored"
|
||||
|
||||
lyr = result["lyrics"]
|
||||
assert [e["w"] for e in lyr] == ["Hel-", "lo", "world"]
|
||||
assert [e["t"] for e in lyr] == pytest.approx([0.0, 0.5, 1.0])
|
||||
# Paired syllables snap d to the note duration (1 beat = 0.5 s).
|
||||
assert [e["d"] for e in lyr] == pytest.approx([0.5, 0.5, 0.5])
|
||||
|
||||
vp = result["vocal_pitch"]
|
||||
assert vp is not None
|
||||
assert vp["version"] == 1
|
||||
assert [n["midi"] for n in vp["notes"]] == [60, 62, 64]
|
||||
# vocal_pitch t/d mirror the matching lyrics entries (spec §7.2).
|
||||
assert [(n["t"], n["d"]) for n in vp["notes"]] == \
|
||||
[(e["t"], e["d"]) for e in lyr]
|
||||
|
||||
|
||||
def test_vocal_program_detection_without_name(tmp_path):
|
||||
"""GM program 53 (Voice Oohs) marks the track vocal even with no name."""
|
||||
path = _vocal_file(tmp_path, track_name="", program=53)
|
||||
result = extract_midi_lyrics(path)
|
||||
assert result is not None
|
||||
assert result["vocal_pitch"] is not None
|
||||
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [60, 62, 64]
|
||||
|
||||
|
||||
# ── lyrics-only fallbacks ────────────────────────────────────────────────────
|
||||
|
||||
def test_no_vocal_track_emits_lyrics_only(tmp_path):
|
||||
"""Lyric events on a noteless track + only a piano note track → the
|
||||
lyrics payload is emitted but vocal_pitch is None (talkies path)."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
||||
conductor = mido.MidiTrack()
|
||||
mid.tracks.append(conductor)
|
||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
|
||||
words = mido.MidiTrack()
|
||||
mid.tracks.append(words)
|
||||
words.append(mido.MetaMessage("lyrics", text="Hello ", time=0))
|
||||
words.append(mido.MetaMessage("lyrics", text="there ", time=TPB))
|
||||
|
||||
piano = mido.MidiTrack()
|
||||
mid.tracks.append(piano)
|
||||
piano.append(mido.MetaMessage("track_name", name="Piano", time=0))
|
||||
piano.append(mido.Message("program_change", channel=0, program=0, time=0))
|
||||
piano.append(mido.Message("note_on", channel=0, note=48, velocity=90, time=0))
|
||||
piano.append(mido.Message("note_off", channel=0, note=48, velocity=0, time=TPB))
|
||||
|
||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
||||
assert result is not None
|
||||
assert [e["w"] for e in result["lyrics"]] == ["Hello", "there"]
|
||||
assert result["vocal_pitch"] is None
|
||||
|
||||
|
||||
def test_no_lyrics_returns_none(tmp_path):
|
||||
"""A file without lyric events changes nothing — extraction reports None."""
|
||||
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
|
||||
tr = mido.MidiTrack()
|
||||
mid.tracks.append(tr)
|
||||
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
|
||||
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
|
||||
|
||||
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
|
||||
|
||||
|
||||
def test_unpaired_duration_next_event_capped_at_2s(tmp_path):
|
||||
"""Unpaired lyric entries last until the next syllable, capped at 2.0 s."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
||||
conductor = mido.MidiTrack()
|
||||
mid.tracks.append(conductor)
|
||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
|
||||
words = mido.MidiTrack()
|
||||
mid.tracks.append(words)
|
||||
words.append(mido.MetaMessage("lyrics", text="one ", time=0))
|
||||
words.append(mido.MetaMessage("lyrics", text="two ", time=TPB)) # +0.5 s
|
||||
words.append(mido.MetaMessage("lyrics", text="three ", time=TPB * 8)) # +4.0 s
|
||||
|
||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
||||
lyr = result["lyrics"]
|
||||
assert lyr[0]["d"] == pytest.approx(0.5) # gap to next syllable
|
||||
assert lyr[1]["d"] == pytest.approx(2.0) # 4.0 s gap capped
|
||||
assert lyr[2]["d"] == pytest.approx(2.0) # last entry: cap value
|
||||
|
||||
|
||||
# ── .kar conventions ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_kar_slash_line_break_maps_to_plus(tmp_path):
|
||||
"""A '/' prefix on a syllable marks the END of the previous line — the
|
||||
previous syllable gains the spec's trailing '+'."""
|
||||
path = _vocal_file(
|
||||
tmp_path, syllables=("Hel-", "lo", "/world"))
|
||||
result = extract_midi_lyrics(path)
|
||||
words = [e["w"] for e in result["lyrics"]]
|
||||
assert words == ["Hel-", "lo+", "world"]
|
||||
|
||||
|
||||
def test_kar_backslash_paragraph_break_maps_to_plus(tmp_path):
|
||||
path = _vocal_file(tmp_path, syllables=("one", "\\two", "three"))
|
||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
||||
assert words == ["one+", "two", "three"]
|
||||
|
||||
|
||||
def test_kar_hyphen_joins_pass_through(tmp_path):
|
||||
""".kar hyphen suffixes already ARE the spec join marker — untouched,
|
||||
and no extra '-' is synthesized onto hyphenless word-final syllables."""
|
||||
path = _vocal_file(tmp_path, syllables=("beau-", "ti-", "ful", "day"))
|
||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
||||
assert words == ["beau-", "ti-", "ful", "day"]
|
||||
|
||||
|
||||
def test_space_delimited_stream_gains_hyphen_joins(tmp_path):
|
||||
"""Space-delimited Lyric streams ('Hel' 'lo ' 'world') carry word
|
||||
boundaries in whitespace — mid-word syllables gain the '-' join."""
|
||||
path = _vocal_file(tmp_path, syllables=("Hel", "lo ", "world "))
|
||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
||||
assert words == ["Hel-", "lo", "world"]
|
||||
|
||||
|
||||
def test_newline_in_lyric_event_ends_line(tmp_path):
|
||||
path = _vocal_file(tmp_path, syllables=("one \n", "two ", "three "))
|
||||
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
|
||||
assert words == ["one+", "two", "three"]
|
||||
|
||||
|
||||
# ── Text-event (0x01) fallback ───────────────────────────────────────────────
|
||||
|
||||
def test_text_event_fallback_on_vocal_track(tmp_path):
|
||||
"""With no 0x05 events anywhere, Text events on a vocal-ish track are
|
||||
accepted as lyrics; '@'-prefixed .kar metadata tokens are dropped."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
||||
conductor = mido.MidiTrack()
|
||||
mid.tracks.append(conductor)
|
||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
|
||||
tr = mido.MidiTrack()
|
||||
mid.tracks.append(tr)
|
||||
tr.append(mido.MetaMessage("track_name", name="Melody", time=0))
|
||||
tr.append(mido.MetaMessage("text", text="@KMIDI KARAOKE FILE", time=0))
|
||||
tr.append(mido.MetaMessage("text", text="@T A Song", time=0))
|
||||
for i, syl in enumerate(("Some ", "words ")):
|
||||
tr.append(mido.MetaMessage("text", text=syl, time=0))
|
||||
tr.append(mido.Message("note_on", channel=0, note=64 + i, velocity=90,
|
||||
time=0))
|
||||
tr.append(mido.Message("note_off", channel=0, note=64 + i, velocity=0,
|
||||
time=TPB))
|
||||
|
||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
||||
assert result is not None
|
||||
assert [e["w"] for e in result["lyrics"]] == ["Some", "words"]
|
||||
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [64, 65]
|
||||
|
||||
|
||||
def test_text_events_on_non_vocal_track_ignored(tmp_path):
|
||||
"""Text events on a plain instrument track (copyright notices, markers)
|
||||
are not lyrics — extraction returns None."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
|
||||
conductor = mido.MidiTrack()
|
||||
mid.tracks.append(conductor)
|
||||
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
|
||||
tr = mido.MidiTrack()
|
||||
mid.tracks.append(tr)
|
||||
tr.append(mido.MetaMessage("track_name", name="Guitar", time=0))
|
||||
tr.append(mido.MetaMessage("text", text="Copyright 2026", time=0))
|
||||
tr.append(mido.Message("note_on", channel=0, note=52, velocity=90, time=0))
|
||||
tr.append(mido.Message("note_off", channel=0, note=52, velocity=0, time=TPB))
|
||||
|
||||
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
|
||||
|
||||
|
||||
# ── format-0 channel isolation ───────────────────────────────────────────────
|
||||
|
||||
def test_format0_pairs_only_vocal_program_channel(tmp_path):
|
||||
"""Format-0 file mixing a vocal-program channel with an accompaniment
|
||||
channel: only the vocal channel's notes feed vocal_pitch."""
|
||||
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
|
||||
tr = mido.MidiTrack()
|
||||
mid.tracks.append(tr)
|
||||
tr.append(mido.Message("program_change", channel=0, program=53, time=0)) # Voice Oohs
|
||||
tr.append(mido.Message("program_change", channel=1, program=0, time=0)) # Piano
|
||||
# Simultaneous piano note that must NOT be paired.
|
||||
tr.append(mido.Message("note_on", channel=1, note=40, velocity=90, time=0))
|
||||
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
|
||||
tr.append(mido.Message("note_on", channel=0, note=67, velocity=90, time=0))
|
||||
tr.append(mido.Message("note_off", channel=0, note=67, velocity=0, time=TPB))
|
||||
tr.append(mido.Message("note_off", channel=1, note=40, velocity=0, time=0))
|
||||
|
||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
||||
assert result is not None
|
||||
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [67]
|
||||
|
||||
|
||||
def test_format0_without_vocal_channel_is_lyrics_only(tmp_path):
|
||||
"""Format-0 with lyrics but no vocal program/name: the merged note soup
|
||||
cannot be trusted as a melody — lyrics.json only."""
|
||||
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
|
||||
tr = mido.MidiTrack()
|
||||
mid.tracks.append(tr)
|
||||
tr.append(mido.Message("program_change", channel=0, program=0, time=0))
|
||||
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
|
||||
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
|
||||
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
|
||||
|
||||
result = extract_midi_lyrics(_save(mid, tmp_path))
|
||||
assert result is not None
|
||||
assert len(result["lyrics"]) == 1
|
||||
assert result["vocal_pitch"] is None
|
||||
|
||||
|
||||
# ── audio_offset ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_audio_offset_applied_to_both_payloads(tmp_path):
|
||||
result = extract_midi_lyrics(_vocal_file(tmp_path), audio_offset=1.5)
|
||||
assert result["lyrics"][0]["t"] == pytest.approx(1.5)
|
||||
assert result["vocal_pitch"]["notes"][0]["t"] == pytest.approx(1.5)
|
||||
@@ -34,7 +34,7 @@ def test_decode_wire_notes_unpacks_midi_and_sorts():
|
||||
arr = {"notes": [_wire(1.0, 67, 0.5), _wire(0.0, 60)]}
|
||||
out = nl.decode_wire_notes(arr)
|
||||
assert [n["midi"] for n in out] == [60, 67]
|
||||
assert out[0] == {"t": 0.0, "midi": 60, "sus": 0.0, "hand": None}
|
||||
assert out[0] == {"t": 0.0, "midi": 60, "sus": 0.0}
|
||||
assert out[1]["sus"] == 0.5
|
||||
|
||||
|
||||
@@ -105,57 +105,6 @@ def test_split_hands_middle_c_split_falls_back_when_it_makes_unplayable_hand():
|
||||
assert sorted(n["midi"] for n in hands["rh"]) == [59, 62, 67]
|
||||
|
||||
|
||||
def test_split_hands_authored_hand_always_wins():
|
||||
# An authored 'lh' melody note ABOVE middle C (a crossing-hands texture):
|
||||
# the heuristic alone would call midi 65 rh; the authored hand wins.
|
||||
notes = [{"t": 0.0, "midi": 65, "sus": 0, "hand": "lh"}]
|
||||
hands = nl.split_hands(notes)
|
||||
assert [n["midi"] for n in hands["lh"]] == [65]
|
||||
assert "rh" not in hands
|
||||
|
||||
|
||||
def test_split_hands_explicit_notes_leave_the_group_before_heuristic_math():
|
||||
# Group [C3(authored rh!), C4, E4]: without removal, C3=48 drags the mean
|
||||
# to (48+60+64)/3 ≈ 57.3 < 60 → the WHOLE group would flip lh. With the
|
||||
# authored note removed first, the remaining [C4, E4] mean 62 ≥ 60 → rh.
|
||||
notes = [
|
||||
{"t": 0.0, "midi": 48, "sus": 0, "hand": "rh"},
|
||||
{"t": 0.0, "midi": 60, "sus": 0},
|
||||
{"t": 0.0, "midi": 64, "sus": 0},
|
||||
]
|
||||
hands = nl.split_hands(notes)
|
||||
assert sorted(n["midi"] for n in hands["rh"]) == [48, 60, 64]
|
||||
assert "lh" not in hands
|
||||
|
||||
|
||||
def test_split_hands_all_explicit_group_skips_heuristic_entirely():
|
||||
notes = [
|
||||
{"t": 0.0, "midi": 40, "sus": 0, "hand": "rh"}, # deliberately "wrong"
|
||||
{"t": 0.0, "midi": 72, "sus": 0, "hand": "lh"}, # crossing hands
|
||||
]
|
||||
hands = nl.split_hands(notes)
|
||||
assert [n["midi"] for n in hands["rh"]] == [40]
|
||||
assert [n["midi"] for n in hands["lh"]] == [72]
|
||||
|
||||
|
||||
def test_split_hands_junk_hand_values_fall_to_the_heuristic():
|
||||
for junk in ("LH", "left", "", True, 3, None):
|
||||
hands = nl.split_hands([{"t": 0.0, "midi": 72, "sus": 0, "hand": junk}])
|
||||
assert [n["midi"] for n in hands.get("rh", [])] == [72], repr(junk)
|
||||
|
||||
|
||||
def test_decode_wire_notes_carries_hand_with_strict_enum():
|
||||
arr = {"notes": [
|
||||
{"t": 0.0, "s": 2, "f": 0, "sus": 0.5, "hand": "lh"},
|
||||
{"t": 0.5, "s": 2, "f": 12, "sus": 0.5, "hand": "LH"}, # junk case
|
||||
{"t": 1.0, "s": 2, "f": 14, "sus": 0.5},
|
||||
], "chords": [
|
||||
{"t": 1.5, "notes": [{"s": 3, "f": 0, "sus": 0.5, "hand": "rh"}]},
|
||||
]}
|
||||
decoded = nl.decode_wire_notes(arr)
|
||||
assert [n["hand"] for n in decoded] == ["lh", None, None, "rh"]
|
||||
|
||||
|
||||
# ── Timing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_downbeat_times_filters_non_downbeats_and_sorts():
|
||||
|
||||
@@ -175,185 +175,3 @@ def test_deleting_playlist_removes_custom_cover(client, server):
|
||||
assert _playlist_cover_path(pid).exists()
|
||||
client.delete(f"/api/playlists/{pid}")
|
||||
assert not _playlist_cover_path(pid).exists()
|
||||
|
||||
|
||||
# ── Reordering the playlists THEMSELVES (not songs-within) ───────────────────
|
||||
|
||||
def _mk(client, name):
|
||||
return client.post("/api/playlists", json={"name": name}).json()["id"]
|
||||
|
||||
|
||||
def _ids(client):
|
||||
return [p["id"] for p in client.get("/api/playlists").json()]
|
||||
|
||||
|
||||
def test_playlists_default_order_is_alphabetical(client):
|
||||
b = _mk(client, "Bravo")
|
||||
a = _mk(client, "alpha") # NOCASE: lowercase still sorts by letter
|
||||
z = _mk(client, "Zulu")
|
||||
assert _ids(client) == [a, b, z]
|
||||
|
||||
|
||||
def test_playlist_manual_reorder_persists(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
c = _mk(client, "Charlie")
|
||||
r = client.post("/api/playlists/reorder", json={"order": [c, a, b]})
|
||||
assert r.status_code == 200
|
||||
assert [p["id"] for p in r.json()] == [c, a, b]
|
||||
# persists across independent list calls
|
||||
assert _ids(client) == [c, a, b]
|
||||
assert _ids(client) == [c, a, b]
|
||||
|
||||
|
||||
def test_playlist_reorder_excludes_system_and_keeps_it_pinned(client):
|
||||
# First toggle creates the "Saved for Later" system playlist.
|
||||
client.post("/api/saved/toggle", json={"filename": "x.archive"})
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
saved = next(p["id"] for p in client.get("/api/playlists").json() if p["system_key"])
|
||||
# A system id in the order is rejected — it isn't reorderable.
|
||||
assert client.post("/api/playlists/reorder", json={"order": [saved, b, a]}).status_code == 400
|
||||
# User playlists reorder; the system playlist stays pinned first.
|
||||
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 200
|
||||
listing = client.get("/api/playlists").json()
|
||||
assert listing[0]["system_key"] == "saved_for_later"
|
||||
assert [p["id"] for p in listing[1:]] == [b, a]
|
||||
|
||||
|
||||
def test_playlist_reorder_rejects_bad_orders(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
for bad in (
|
||||
[a], # missing an id (partial order)
|
||||
[a, b, 999999], # extra unknown id
|
||||
[a, a], # duplicate (drops b)
|
||||
[a, 999999], # unknown id in place of b
|
||||
"nope", # not a list
|
||||
[a, str(b)], # non-int entry
|
||||
[True, False], # bools are ints to Python — must still be rejected
|
||||
None, # {"order": null}
|
||||
):
|
||||
assert client.post("/api/playlists/reorder", json={"order": bad}).status_code == 400, bad
|
||||
assert client.post("/api/playlists/reorder", json={}).status_code == 400
|
||||
# Nothing was persisted by any rejected request.
|
||||
assert _ids(client) == [a, b]
|
||||
|
||||
|
||||
def test_sort_alpha_clears_manual_order(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
z = _mk(client, "Zulu")
|
||||
client.post("/api/playlists/reorder", json={"order": [z, b, a]})
|
||||
assert _ids(client) == [z, b, a]
|
||||
r = client.post("/api/playlists/sort-alpha")
|
||||
assert r.status_code == 200
|
||||
assert [p["id"] for p in r.json()] == [a, b, z]
|
||||
assert _ids(client) == [a, b, z]
|
||||
|
||||
|
||||
def test_new_playlist_after_manual_reorder_sorts_alphabetically_after_positioned(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
client.post("/api/playlists/reorder", json={"order": [b, a]})
|
||||
# New playlists are unpositioned → they follow the manually positioned
|
||||
# ones, alphabetically among themselves, and never disturb the manual
|
||||
# order ("Aardvark" would be first alphabetically).
|
||||
z = _mk(client, "Zebra")
|
||||
aa = _mk(client, "Aardvark")
|
||||
assert _ids(client) == [b, a, aa, z]
|
||||
# A subsequent full reorder must include the newcomers (exact permutation).
|
||||
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 400
|
||||
assert client.post("/api/playlists/reorder", json={"order": [z, aa, b, a]}).status_code == 200
|
||||
assert _ids(client) == [z, aa, b, a]
|
||||
|
||||
|
||||
# ── Tuning-check payload (per-song data the playlist tuning check scores) ────
|
||||
# A playlist grouped BY TUNING is a run you can practise without retuning, so
|
||||
# the detail view flags rows your instrument can't reach. Scoring needs more
|
||||
# than the tuning NAME: two "Custom Tuning" rows are different tunings, and a
|
||||
# bass-only chart has to be measured against bass base pitches.
|
||||
|
||||
def test_playlist_songs_carry_tuning_offsets_for_the_check(client, server):
|
||||
db = server.meta_db
|
||||
db.put("drop.archive", 0, 0, {"title": "Drop", "tuning_name": "Drop D",
|
||||
"tuning_offsets": "-2 0 0 0 0 0"})
|
||||
pid = client.post("/api/playlists", json={"name": "T"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "drop.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
assert song["tuning_name"] == "Drop D"
|
||||
|
||||
|
||||
def test_playlist_songs_carry_role_specific_tunings(client, server):
|
||||
db = server.meta_db
|
||||
db.put("roles.archive", 0, 0, {
|
||||
"title": "Roles",
|
||||
"tuning_name": "E Standard",
|
||||
"tuning_offsets": "0 0 0 0 0 0",
|
||||
"bass_tuning_name": "A Standard",
|
||||
"bass_tuning_offsets": "-2 -2 -2 -2 -2 -2",
|
||||
"rhythm_tuning_name": "Drop D",
|
||||
"rhythm_tuning_offsets": "-2 0 0 0 0 0",
|
||||
})
|
||||
pid = client.post("/api/playlists", json={"name": "Roles"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "roles.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["bass_tuning_name"] == "A Standard"
|
||||
assert song["bass_tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
|
||||
assert song["rhythm_tuning_name"] == "Drop D"
|
||||
assert song["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
|
||||
|
||||
def test_playlist_songs_flag_bass_only_charts(client, server):
|
||||
# Every arrangement a bass part → bass_only, so coverage scores the row
|
||||
# against bass strings. A chart that ALSO has a guitar part must not be
|
||||
# flagged, or a guitarist's row gets measured on the wrong instrument.
|
||||
db = server.meta_db
|
||||
db.put("bassonly.archive", 0, 0, {"title": "Bass Only", "arrangements": [
|
||||
{"name": "Bass"}, {"name": "Alt. Bass"}]})
|
||||
db.put("mixed.archive", 0, 0, {"title": "Mixed", "arrangements": [
|
||||
{"name": "Lead"}, {"name": "Bass"}]})
|
||||
db.put("noarr.archive", 0, 0, {"title": "No Arrangements"})
|
||||
pid = client.post("/api/playlists", json={"name": "B"}).json()["id"]
|
||||
for fn in ("bassonly.archive", "mixed.archive", "noarr.archive"):
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
|
||||
got = {s["filename"]: s["bass_only"] for s in client.get(f"/api/playlists/{pid}").json()["songs"]}
|
||||
assert got == {"bassonly.archive": True, "mixed.archive": False, "noarr.archive": False}
|
||||
|
||||
|
||||
def test_bass_only_flag_survives_adversarial_arrangement_data(client, server):
|
||||
# Corrupt/odd `arrangements` must not 500 the playlist, and must not claim
|
||||
# bass — an unscoreable row is left for the client to report as "unknown".
|
||||
db = server.meta_db
|
||||
cases = {
|
||||
"empty.archive": [],
|
||||
"unnamed.archive": [{"name": ""}],
|
||||
"nullname.archive": [{"name": None}],
|
||||
"substring.archive": [{"name": "Bassoon"}], # not a bass part
|
||||
"cased.archive": [{"name": "BASS"}], # is one
|
||||
}
|
||||
for fn, arrs in cases.items():
|
||||
db.put(fn, 0, 0, {"title": fn, "arrangements": arrs})
|
||||
pid = client.post("/api/playlists", json={"name": "Adv"}).json()["id"]
|
||||
for fn in cases:
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
|
||||
r = client.get(f"/api/playlists/{pid}")
|
||||
assert r.status_code == 200
|
||||
got = {s["filename"]: s["bass_only"] for s in r.json()["songs"]}
|
||||
assert got == {"empty.archive": False, "unnamed.archive": False,
|
||||
"nullname.archive": False, "substring.archive": False,
|
||||
"cased.archive": True}
|
||||
|
||||
|
||||
def test_playlist_song_with_no_tuning_data_reports_empty_not_missing(client, server):
|
||||
# The key must always be present: the client distinguishes "no tuning data"
|
||||
# (unknown — say nothing) from "wrong tuning" (flag it), and a missing key
|
||||
# would make every row unscoreable by accident rather than by fact.
|
||||
db = server.meta_db
|
||||
db.put("bare.archive", 0, 0, {"title": "Bare"})
|
||||
pid = client.post("/api/playlists", json={"name": "Bare"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "bare.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["tuning_offsets"] == ""
|
||||
assert song["bass_only"] is False
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Loader coverage for MULTIPLE drum parts (feedpak 1.17.0 "drums as
|
||||
arrangements").
|
||||
|
||||
A drum part rides the manifest as a `type: drums` arrangement entry carrying
|
||||
a per-arrangement `drum_tab` file pointer and NO note `file`. The loader:
|
||||
|
||||
- NEVER turns a pointer entry into a fretted Arrangement — that skip is
|
||||
the grading invariant (an empty drum chart must not reach the fretted
|
||||
pipeline, where note detection would grade it as garbage);
|
||||
- resolves the parts into `LoadedSloppak.drum_parts`, primary FIRST: the
|
||||
entry aliasing the song-level `drum_tab:` file contributes its id/name
|
||||
but is never loaded twice (its payload IS `loaded.drum_tab`);
|
||||
- loads each extra part's file with the same permissive posture as the
|
||||
song-level tab (a bad part disables that part only, never the load);
|
||||
- copes with a pointer-only pack (no song-level key): the first part
|
||||
becomes the primary so every legacy consumer keeps working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
def _tab(name: str, hits: list[dict] | None = None) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"name": name,
|
||||
"kit": [{"id": "kick", "name": "Kick"}],
|
||||
"hits": hits if hits is not None else [{"t": 1.0, "p": "kick", "v": 100}],
|
||||
}
|
||||
|
||||
|
||||
def _write_pak(root: Path, manifest_extras: dict, files: dict[str, dict | str]) -> Path:
|
||||
"""A minimal directory-form sloppak with one Lead arrangement plus the
|
||||
given extra files ({relpath: json-dict-or-raw-text})."""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
arr = {
|
||||
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": [], "sections": [],
|
||||
}
|
||||
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||
manifest = {
|
||||
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
],
|
||||
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||
}
|
||||
manifest.update(manifest_extras)
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
for rel, payload in files.items():
|
||||
text = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
(pak / rel).write_text(text)
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak_path: Path, tmp_path: Path):
|
||||
dlc_root = pak_path.parent
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
|
||||
|
||||
|
||||
def _two_part_manifest() -> dict:
|
||||
"""The exact shape the editor writes: primary alias entry + one extra."""
|
||||
return {
|
||||
"drum_tab": "drum_tab.json",
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "drums", "name": "Drums", "type": "drums",
|
||||
"drum_tab": "drum_tab.json"},
|
||||
{"id": "drums-2", "name": "Drums (Live)", "type": "drums",
|
||||
"drum_tab": "drum_tab_drums-2.json"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── The grading invariant ────────────────────────────────────────────────────
|
||||
|
||||
def test_pointer_entries_never_become_fretted_arrangements(tmp_path: Path):
|
||||
pak = _write_pak(tmp_path, _two_part_manifest(), {
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
"drum_tab_drums-2.json": _tab("Drums (Live)"),
|
||||
})
|
||||
loaded = _load(pak, tmp_path)
|
||||
# Only the Lead chart is an Arrangement — neither drum part enters the
|
||||
# fretted pipeline (song.arrangements is what note detection grades).
|
||||
assert [a.name for a in loaded.song.arrangements] == ["Lead"]
|
||||
# And the ids list stays parallel to song.arrangements (skipped entries
|
||||
# contribute nothing) — a misalignment here would remap every chart edit.
|
||||
assert loaded.arrangement_ids == ["lead"]
|
||||
|
||||
|
||||
def test_drums_typed_entry_with_note_file_never_frets(tmp_path: Path):
|
||||
# A malformed entry: type:drums but ALSO carrying a note `file`. Keying the
|
||||
# skip on file absence would let it through as a fretted, selectable,
|
||||
# gradeable Arrangement (spec §5.2/§7.5 MUST-NOT). Routing on `type` first
|
||||
# drops it instead — it never reaches song.arrangements.
|
||||
bogus = {
|
||||
"name": "Bogus", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": [], "sections": [],
|
||||
}
|
||||
pak = _write_pak(tmp_path, {
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "bad", "name": "Bogus", "type": "drums",
|
||||
"file": "arrangements/bogus.json"},
|
||||
],
|
||||
}, {"arrangements/bogus.json": bogus})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [a.name for a in loaded.song.arrangements] == ["Lead"]
|
||||
assert loaded.arrangement_ids == ["lead"]
|
||||
|
||||
|
||||
# ── Parts resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_two_parts_resolve_primary_first_with_alias_identity(tmp_path: Path):
|
||||
pak = _write_pak(tmp_path, _two_part_manifest(), {
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
"drum_tab_drums-2.json": _tab("Drums (Live)", [{"t": 2.0, "p": "kick", "v": 90}]),
|
||||
})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.drum_parts is not None
|
||||
assert [(p["id"], p["name"]) for p in loaded.drum_parts] == [
|
||||
("drums", "Drums"), ("drums-2", "Drums (Live)"),
|
||||
]
|
||||
# The primary's payload IS the song-level tab — same object, loaded once.
|
||||
assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab
|
||||
assert loaded.drum_parts[1]["drum_tab"]["hits"][0]["t"] == 2.0
|
||||
|
||||
|
||||
def test_primary_pointer_equivalent_path_is_not_duplicated(tmp_path: Path):
|
||||
manifest = {
|
||||
"drum_tab": "drum_tab.json",
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "kit", "name": "Live Kit", "type": "drums",
|
||||
"drum_tab": "./drum_tab.json"},
|
||||
],
|
||||
}
|
||||
pak = _write_pak(tmp_path, manifest, {"drum_tab.json": _tab("Drums")})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.drum_parts is not None
|
||||
assert [(p["id"], p["name"]) for p in loaded.drum_parts] == [
|
||||
("kit", "Live Kit"),
|
||||
]
|
||||
assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab
|
||||
|
||||
|
||||
def test_legacy_single_drum_pack_gets_a_one_part_list(tmp_path: Path):
|
||||
pak = _write_pak(tmp_path, {"drum_tab": "drum_tab.json"}, {
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.drum_parts is not None and len(loaded.drum_parts) == 1
|
||||
assert loaded.drum_parts[0]["id"] == "drums"
|
||||
assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab
|
||||
|
||||
|
||||
def test_no_drums_means_no_parts(tmp_path: Path):
|
||||
pak = _write_pak(tmp_path, {}, {})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.drum_parts is None
|
||||
assert loaded.drum_tab is None
|
||||
|
||||
|
||||
def test_pointer_only_pack_promotes_the_first_part_to_primary(tmp_path: Path):
|
||||
# A writer that omitted the song-level alias: readers must cope (the
|
||||
# spec keeps the alias, but a reader never crashes on its absence).
|
||||
pak = _write_pak(tmp_path, {
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "kit", "name": "Kit", "type": "drums",
|
||||
"drum_tab": "drum_tab_kit.json"},
|
||||
],
|
||||
}, {"drum_tab_kit.json": _tab("Kit")})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.drum_parts is not None and len(loaded.drum_parts) == 1
|
||||
# The part's tab becomes THE drum tab, so has_drum_tab / the default
|
||||
# stream / the drum-only placeholder all keep working.
|
||||
assert loaded.drum_tab is loaded.drum_parts[0]["drum_tab"]
|
||||
assert loaded.drum_parts[0]["id"] == "kit"
|
||||
|
||||
|
||||
# ── Permissive per-part failure ──────────────────────────────────────────────
|
||||
|
||||
def test_a_bad_extra_part_disables_that_part_only(tmp_path: Path):
|
||||
manifest = _two_part_manifest()
|
||||
manifest["arrangements"].append(
|
||||
{"id": "drums-3", "name": "Broken", "type": "drums",
|
||||
"drum_tab": "drum_tab_broken.json"})
|
||||
pak = _write_pak(tmp_path, manifest, {
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
"drum_tab_drums-2.json": _tab("Drums (Live)"),
|
||||
"drum_tab_broken.json": "not json {{{",
|
||||
})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2"]
|
||||
|
||||
|
||||
def test_a_traversal_part_path_is_skipped(tmp_path: Path):
|
||||
manifest = _two_part_manifest()
|
||||
manifest["arrangements"][2]["drum_tab"] = "../outside.json"
|
||||
(tmp_path / "outside.json").write_text(json.dumps(_tab("Evil")))
|
||||
pak = _write_pak(tmp_path, manifest, {"drum_tab.json": _tab("Drums")})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [p["id"] for p in loaded.drum_parts] == ["drums"]
|
||||
|
||||
|
||||
def test_duplicate_pointer_rels_load_once(tmp_path: Path):
|
||||
manifest = _two_part_manifest()
|
||||
manifest["arrangements"].append(
|
||||
{"id": "drums-dup", "name": "Dup", "type": "drums",
|
||||
"drum_tab": "drum_tab_drums-2.json"})
|
||||
pak = _write_pak(tmp_path, manifest, {
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
"drum_tab_drums-2.json": _tab("Drums (Live)"),
|
||||
})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2"]
|
||||
|
||||
|
||||
def test_duplicate_part_ids_are_made_unique(tmp_path: Path):
|
||||
manifest = _two_part_manifest()
|
||||
manifest["arrangements"][2]["id"] = "drums"
|
||||
manifest["arrangements"].append(
|
||||
{"id": "drums-2", "name": "Aux", "type": "drums",
|
||||
"drum_tab": "drum_tab_aux.json"})
|
||||
pak = _write_pak(tmp_path, manifest, {
|
||||
"drum_tab.json": _tab("Drums"),
|
||||
"drum_tab_drums-2.json": _tab("Drums (Live)"),
|
||||
"drum_tab_aux.json": _tab("Aux"),
|
||||
})
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2", "drums-3"]
|
||||
|
||||
|
||||
def test_drum_pointer_with_wrong_type_logs_warning(tmp_path: Path, caplog):
|
||||
pak = _write_pak(tmp_path, {
|
||||
"arrangements": [
|
||||
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
|
||||
{"id": "typo", "type": "druns", "drum_tab": "drum_tab_typo.json"},
|
||||
],
|
||||
}, {"drum_tab_typo.json": _tab("Typo")})
|
||||
# feedBack sets propagate=False, so pytest's root capture sees nothing from
|
||||
# it — attach caplog's handler to the feedBack logger and pin WARNING
|
||||
# regardless of ambient level (a sibling test can leak ERROR onto this tree).
|
||||
lg = logging.getLogger("feedBack")
|
||||
orig_level = lg.level
|
||||
lg.addHandler(caplog.handler)
|
||||
lg.setLevel(logging.WARNING)
|
||||
try:
|
||||
loaded = _load(pak, tmp_path)
|
||||
finally:
|
||||
lg.removeHandler(caplog.handler)
|
||||
lg.setLevel(orig_level)
|
||||
assert loaded.drum_parts is None
|
||||
assert "has drum_tab" in caplog.text and "type='druns'" in caplog.text
|
||||
|
||||
|
||||
# ── Drum-only pack with parts ────────────────────────────────────────────────
|
||||
|
||||
def test_drum_only_pack_with_pointer_entries_still_synthesizes_placeholder(tmp_path: Path):
|
||||
# No pitched arrangements at all, drums via pointer entries only: the
|
||||
# placeholder "Drums" arrangement must still appear so the highway WS
|
||||
# proceeds and the tab reaches the drum highway.
|
||||
pak = _write_pak(tmp_path, {
|
||||
"arrangements": [
|
||||
{"id": "kit", "name": "Kit", "type": "drums",
|
||||
"drum_tab": "drum_tab_kit.json"},
|
||||
],
|
||||
}, {"drum_tab_kit.json": _tab("Kit", [{"t": 5.0, "p": "kick", "v": 100}])})
|
||||
# Remove the Lead arrangement _write_pak added to the manifest.
|
||||
manifest_path = pak / "manifest.yaml"
|
||||
manifest = yaml.safe_load(manifest_path.read_text())
|
||||
manifest["arrangements"] = [e for e in manifest["arrangements"] if e.get("id") != "lead"]
|
||||
manifest.pop("duration", None)
|
||||
manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [a.name for a in loaded.song.arrangements] == ["Drums"]
|
||||
assert loaded.drum_parts is not None and loaded.drum_parts[0]["id"] == "kit"
|
||||
# Song length derived from the last hit (the drum-only path's rule).
|
||||
assert loaded.song.song_length > 5.0
|
||||
@@ -249,34 +249,6 @@ def test_note_teaching_marks_tolerate_malformed_optional_ints():
|
||||
assert n.scale_degree == -1
|
||||
|
||||
|
||||
# ── Keys hand assignment ─────────────────────────────────────────────────────
|
||||
|
||||
def test_note_hand_round_trips_under_literal_key():
|
||||
"""The keys hand assignment survives the wire as the literal `hand` key
|
||||
(spelled out — `rh` is taken by right_hand, the bass plucking finger)."""
|
||||
for hand in ("lh", "rh"):
|
||||
n = Note(time=0.0, string=2, fret=12, hand=hand)
|
||||
wire = note_to_wire(n)
|
||||
assert wire["hand"] == hand
|
||||
assert note_from_wire(wire) == n
|
||||
|
||||
|
||||
def test_note_hand_omitted_when_unassigned():
|
||||
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
|
||||
assert "hand" not in wire
|
||||
assert note_from_wire(wire).hand is None
|
||||
|
||||
|
||||
def test_note_hand_junk_never_emitted_and_decodes_to_unassigned():
|
||||
"""Emit side validates ('LH', True, … stay off the wire); decode side is a
|
||||
strict enum so a hand-edited pack can't poison hand-split logic."""
|
||||
for junk in ("LH", "left", "", True, 1, ["lh"]):
|
||||
assert "hand" not in note_to_wire(
|
||||
Note(time=0.0, string=0, fret=0, hand=junk))
|
||||
assert note_from_wire(
|
||||
{"t": 0.0, "s": 0, "f": 0, "hand": junk}).hand is None
|
||||
|
||||
|
||||
# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ──────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("key,pc", [
|
||||
@@ -329,31 +301,6 @@ def test_note_pitch_midi_bass_uses_bass_base():
|
||||
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
||||
|
||||
|
||||
def test_note_pitch_midi_authored_bass_type_uses_bass_base():
|
||||
"""Editor PR #335: a bass authored via `type` on an arrangement whose NAME
|
||||
doesn't say "bass". arrangement_string_count now returns 4 for it, so the
|
||||
open-string base MUST also be the bass base (low E1 = 28), not the guitar
|
||||
octave (40). Pre-fix note_pitch_midi keyed is_bass off the name only, so
|
||||
this returned 40 (4 lanes on a guitar octave — the exact inconsistency)."""
|
||||
bass = Arrangement(
|
||||
name="Low End", type="bass",
|
||||
tuning=[0, 0, 0, 0],
|
||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
||||
)
|
||||
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
||||
|
||||
|
||||
def test_note_pitch_midi_path_bass_flag_uses_bass_base():
|
||||
"""Same guarantee via the archive <arrangementProperties> pathBass flag on a
|
||||
non-"bass"-named arrangement: bass base (28), not guitar (40)."""
|
||||
bass = Arrangement(
|
||||
name="Low End", path_bass=True,
|
||||
tuning=[0, 0, 0, 0],
|
||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
||||
)
|
||||
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
|
||||
|
||||
|
||||
def test_note_pitch_midi_out_of_range_string_is_none():
|
||||
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
|
||||
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
|
||||
@@ -1178,60 +1125,6 @@ def test_string_count_ignores_rs_padded_tuning_for_bass():
|
||||
assert arrangement_string_count(arr) == 4
|
||||
|
||||
|
||||
def test_string_count_4_for_path_bass_flag_without_bass_in_name():
|
||||
# Archive/DLC bass whose manifest ArrangementName isn't "Bass" but whose
|
||||
# <arrangementProperties> pathBass flag is set. Notes on 0..3, tuning
|
||||
# padded to the arrangement-XML length of 6. Pre-fix, name_based forced
|
||||
# 6 (name has no "bass"), so this returned 6 despite the authoritative
|
||||
# instrument flag saying bass.
|
||||
arr = Arrangement(
|
||||
name="Low End",
|
||||
path_bass=True,
|
||||
tuning=[0, 0, 0, 0, 0, 0],
|
||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
||||
)
|
||||
assert arrangement_string_count(arr) == 4
|
||||
|
||||
|
||||
def test_string_count_4_for_authored_bass_type_without_bass_in_name():
|
||||
# Editor PR #335: an instrument `type` authored as bass on an arrangement
|
||||
# whose NAME does not contain "bass" (the sloppak loader lifts the manifest
|
||||
# `type` onto Arrangement.type). Notes on 0..3, tuning padded to 6.
|
||||
# The editor lays out 4 lanes off the type; core must agree.
|
||||
arr = Arrangement(
|
||||
name="Low End",
|
||||
type="bass",
|
||||
tuning=[0, 0, 0, 0, 0, 0],
|
||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
||||
)
|
||||
assert arrangement_string_count(arr) == 4
|
||||
|
||||
|
||||
def test_string_count_6_for_authored_guitar_type_no_regression():
|
||||
# A non-bass authored type on a generic name still resolves to the
|
||||
# canonical 6 — the type signal only pulls DOWN to 4 for bass.
|
||||
arr = Arrangement(
|
||||
name="Track 1",
|
||||
type="guitar",
|
||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(5)],
|
||||
)
|
||||
assert arrangement_string_count(arr) == 6
|
||||
|
||||
|
||||
def test_arrangement_is_bass_signal_safety():
|
||||
# The manifest `type` is lifted onto arr.type verbatim; the helper must be
|
||||
# safe against the messy shapes a hand-edited/loose source can produce.
|
||||
from song import arrangement_is_bass
|
||||
assert arrangement_is_bass(Arrangement(name="Low End", type="bass"))
|
||||
assert arrangement_is_bass(Arrangement(name="Low End", type=" BASS ")) # ws/case
|
||||
assert arrangement_is_bass(Arrangement(name="Low End", path_bass=True))
|
||||
assert arrangement_is_bass(Arrangement(name="Slap Bass")) # legacy name
|
||||
# Non-bass / absent signals stay False (back-compat: no bass signal → 6).
|
||||
assert not arrangement_is_bass(Arrangement(name="Lead", type=""))
|
||||
assert not arrangement_is_bass(Arrangement(name="Rhythm", type="guitar"))
|
||||
assert not arrangement_is_bass(Arrangement(name="", type=""))
|
||||
|
||||
|
||||
# ── compute_smart_names ───────────────────────────────────────────────────────
|
||||
|
||||
def _sarr(path_lead=False, path_rhythm=False, path_bass=False,
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""`/api/song/{f}?stems=1` — the playable stem list, for preloading.
|
||||
|
||||
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 the stems and then
|
||||
copied the whole song's PCM to its audio worklet with the player on screen —
|
||||
half a gigabyte of memcpy in one frame, ~700 ms, which froze 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 safety property these tests exist for: the REST payload must be the SAME
|
||||
list the WS builds. If they disagree, the plugin preloads one graph and then
|
||||
throws it away and rebuilds another — strictly worse than not preloading. So
|
||||
they are pinned against each other, not just against a snapshot.
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak
|
||||
|
||||
|
||||
def _pak(tmp_path, stems, full=None, name="song.feedpak", original_audio=None):
|
||||
manifest = {
|
||||
"title": "T", "artist": "A", "duration": 10.0,
|
||||
"arrangements": [],
|
||||
"stems": stems + ([full] if full else []),
|
||||
}
|
||||
if original_audio:
|
||||
# The deprecated pre-1.15.0 shape: the mixdown lives outside `stems`.
|
||||
manifest["original_audio"] = original_audio
|
||||
p = tmp_path / name
|
||||
with zipfile.ZipFile(p, "w") as z:
|
||||
# Real packs carry manifest.yaml — a JSON manifest is not read at all.
|
||||
z.writestr("manifest.yaml", yaml.safe_dump(manifest))
|
||||
# _legacy_full_mix only returns a path that actually EXISTS on disk.
|
||||
if original_audio:
|
||||
z.writestr(original_audio, b"\0" * 16)
|
||||
return p
|
||||
|
||||
|
||||
def _payload(tmp_path, pak):
|
||||
from routers.song import _playable_stems_payload
|
||||
import appstate
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
appstate.sloppak_cache_dir = cache
|
||||
return _playable_stems_payload(pak.name, tmp_path)
|
||||
|
||||
|
||||
def _ws_payload(tmp_path, pak):
|
||||
"""Rebuild the WS `ready` stems payload exactly as ws_highway.py does."""
|
||||
from urllib.parse import quote
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
loaded = sloppak.load_song(pak.name, tmp_path, cache)
|
||||
q = quote(pak.name, safe="")
|
||||
return {
|
||||
"stems": [
|
||||
{"id": s["id"], "url": f"/api/sloppak/{q}/file/{quote(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": f"/api/sloppak/{q}/file/{quote(loaded.full_mix)}" if loaded.full_mix else None,
|
||||
}
|
||||
|
||||
|
||||
def test_default_resolution_is_shared_with_load_song():
|
||||
assert sloppak.stem_default_on(True) is True
|
||||
assert sloppak.stem_default_on(False) is False
|
||||
assert sloppak.stem_default_on("off") is False
|
||||
assert sloppak.stem_default_on("false") is False
|
||||
assert sloppak.stem_default_on("0") is False
|
||||
assert sloppak.stem_default_on("no") is False
|
||||
assert sloppak.stem_default_on("on") is True
|
||||
assert sloppak.stem_default_on(1) is True
|
||||
|
||||
|
||||
def test_rest_matches_the_ws_for_a_reserved_full_stem(tmp_path):
|
||||
pak = _pak(tmp_path,
|
||||
[{"id": "guitar", "file": "stems/guitar.ogg"},
|
||||
{"id": "vocals", "file": "stems/vocals.ogg", "default": "off"}],
|
||||
full={"id": "full", "file": "stems/full.ogg"},
|
||||
name="Iron Maiden - Phantom.feedpak")
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
assert [s["id"] for s in rest["stems"]] == ["guitar", "vocals"], "the mixdown is not a layer"
|
||||
assert rest["full_mix_url"].endswith("stems/full.ogg")
|
||||
assert rest["stems"][1]["default"] is False
|
||||
|
||||
|
||||
def test_rest_matches_the_ws_for_a_LEGACY_original_audio_pack(tmp_path):
|
||||
"""The one CodeRabbit caught, and the one that matters most in practice.
|
||||
|
||||
load_song falls back to the DEPRECATED `original_audio:` key when a pack has
|
||||
no reserved `full` stem — which is every pack written before feedpak 1.15.0,
|
||||
i.e. most of a real library. My first version of this payload reimplemented
|
||||
the full-mix rule from extract_meta and silently returned None for them: REST
|
||||
would say "no full mix" while the WS said there was one. The plugin would then
|
||||
preload a graph WITHOUT the pristine mix and, because the signature still
|
||||
matched, never rebuild — unity playback silently downgraded to the lossy
|
||||
recombination.
|
||||
|
||||
The payload now calls load_song itself, so this cannot drift. Pinned anyway.
|
||||
"""
|
||||
pak = _pak(tmp_path, [
|
||||
{"id": "guitar", "file": "stems/guitar.ogg"},
|
||||
{"id": "bass", "file": "stems/bass.ogg"},
|
||||
], name="Legacy Pack.feedpak", original_audio="original/full.ogg")
|
||||
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
assert rest["full_mix_url"] is not None, (
|
||||
"a pre-1.15.0 pack's full mix must survive — dropping it downgrades unity "
|
||||
"playback to the lossy stem recombination, silently"
|
||||
)
|
||||
assert rest["full_mix_url"].endswith("original/full.ogg")
|
||||
|
||||
|
||||
def test_rest_matches_the_ws_for_a_single_full_pack(tmp_path):
|
||||
# Its ONE stem IS the mixdown: nothing to be pristine against, so `full` stays
|
||||
# the sole playable stem and no separate mixdown is surfaced.
|
||||
pak = _pak(tmp_path, [{"id": "full", "file": "stems/full.ogg"}], name="Single.feedpak")
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
assert [s["id"] for s in rest["stems"]] == ["full"]
|
||||
assert rest["full_mix_url"] is None
|
||||
|
||||
|
||||
def test_stem_name_and_description_pass_through(tmp_path):
|
||||
"""feedpak 1.16.0 per-stem `name`/`description` (spec §5.3) reach the payload.
|
||||
|
||||
Presentational, so the rule is passthrough-or-omit: a stem that carries the
|
||||
fields keeps them, a stem that doesn't must NOT grow null keys, and
|
||||
non-string / blank values are dropped rather than surfaced.
|
||||
"""
|
||||
pak = _pak(tmp_path, [
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "name": "Rhythm Guitar"},
|
||||
{"id": "click", "file": "stems/click.ogg", "name": "Click",
|
||||
"description": "Metronome click with 4-count lead-in.", "default": "off"},
|
||||
{"id": "bass", "file": "stems/bass.ogg"},
|
||||
{"id": "junk", "file": "stems/junk.ogg", "name": 7, "description": " "},
|
||||
], name="Labelled.feedpak")
|
||||
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
by_id = {s["id"]: s for s in rest["stems"]}
|
||||
assert by_id["guitar"]["name"] == "Rhythm Guitar"
|
||||
assert "description" not in by_id["guitar"]
|
||||
assert by_id["click"]["name"] == "Click"
|
||||
assert by_id["click"]["description"] == "Metronome click with 4-count lead-in."
|
||||
assert by_id["click"]["default"] is False
|
||||
assert "name" not in by_id["bass"] and "description" not in by_id["bass"]
|
||||
assert "name" not in by_id["junk"] and "description" not in by_id["junk"]
|
||||
|
||||
|
||||
def test_a_broken_pack_yields_an_empty_list_not_an_error(tmp_path):
|
||||
# Preloading is an optimisation: an unreadable pack must fall back to the
|
||||
# normal WS-driven path, never break the song-info request.
|
||||
from routers.song import _playable_stems_payload
|
||||
import appstate
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
appstate.sloppak_cache_dir = cache
|
||||
(tmp_path / "bad.feedpak").write_bytes(b"not a zip")
|
||||
assert _playable_stems_payload("bad.feedpak", tmp_path) == {"stems": [], "full_mix_url": None}
|
||||
@@ -60,14 +60,12 @@ def test_the_failure_is_actually_logged(registry, caplog):
|
||||
# capture_logger() context manager for this, but it is not importable from here:
|
||||
# pyproject pins pythonpath to [".", "lib"], so `tests` is not a package.)
|
||||
lg = logging.getLogger("feedBack")
|
||||
orig_level = lg.level
|
||||
lg.addHandler(caplog.handler)
|
||||
lg.setLevel(logging.ERROR)
|
||||
try:
|
||||
registry.get_merged()
|
||||
finally:
|
||||
lg.removeHandler(caplog.handler)
|
||||
lg.setLevel(orig_level) # restore, or ERROR leaks onto the feedBack tree
|
||||
|
||||
assert any("bad-plugin" in r.getMessage() for r in caplog.records), (
|
||||
"the raising provider was never named in the logs"
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Tests for the session-sync relay WebSocket (/ws/sync/{session_id}).
|
||||
|
||||
Behavior tests run against a minimal FastAPI app carrying just the router
|
||||
(fast — no full-server import); one integration test imports the real server
|
||||
to pin that the route is actually mounted there.
|
||||
|
||||
Covers the feedBack#1030 acceptance list: bidirectional fan-out, late join,
|
||||
sender never echoed, room garbage collection, and the limit closes (invalid
|
||||
session id, binary frames, frame size, room size, room count, rate cap) —
|
||||
including that one client tripping a limit doesn't disturb the others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from routers import ws_sync
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_rooms():
|
||||
ws_sync._rooms.clear()
|
||||
yield
|
||||
ws_sync._rooms.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(ws_sync.router)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _expect_close(ws, code):
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
ws.receive_text()
|
||||
assert exc.value.code == code
|
||||
|
||||
|
||||
# ── Fan-out semantics ────────────────────────────────────────────────────────
|
||||
|
||||
def test_two_clients_relay_both_directions_and_no_echo(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM01") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM01") as b:
|
||||
a.send_text('{"type":"time","t":1.5}')
|
||||
assert b.receive_text() == '{"type":"time","t":1.5}'
|
||||
b.send_text('{"type":"hello"}')
|
||||
# A's first inbound frame is B's hello — NOT an echo of its own send.
|
||||
assert a.receive_text() == '{"type":"hello"}'
|
||||
|
||||
|
||||
def test_late_joiner_receives_subsequent_frames(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM02") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM02") as b:
|
||||
a.send_text("f1")
|
||||
assert b.receive_text() == "f1"
|
||||
with client.websocket_connect("/ws/sync/ROOM02") as c:
|
||||
a.send_text("f2")
|
||||
assert b.receive_text() == "f2"
|
||||
assert c.receive_text() == "f2"
|
||||
|
||||
|
||||
def test_rooms_are_isolated(client):
|
||||
with client.websocket_connect("/ws/sync/ROOMA1") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOMB1") as b, \
|
||||
client.websocket_connect("/ws/sync/ROOMA1") as a2:
|
||||
a.send_text("for-room-a")
|
||||
assert a2.receive_text() == "for-room-a"
|
||||
# B (other room) got nothing: prove it by relaying within B's room.
|
||||
with client.websocket_connect("/ws/sync/ROOMB1") as b2:
|
||||
b2.send_text("for-room-b")
|
||||
assert b.receive_text() == "for-room-b"
|
||||
|
||||
|
||||
def test_client_disconnect_does_not_disrupt_remaining(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM03") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM03") as b:
|
||||
with client.websocket_connect("/ws/sync/ROOM03") as c:
|
||||
a.send_text("before")
|
||||
assert b.receive_text() == "before"
|
||||
assert c.receive_text() == "before"
|
||||
# C is gone; relay between A and B continues.
|
||||
a.send_text("after")
|
||||
assert b.receive_text() == "after"
|
||||
|
||||
|
||||
def test_room_garbage_collected_when_last_client_leaves(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM04") as a:
|
||||
with client.websocket_connect("/ws/sync/ROOM04") as b:
|
||||
a.send_text("x")
|
||||
assert b.receive_text() == "x"
|
||||
assert "ROOM04" in ws_sync._rooms
|
||||
assert "ROOM04" not in ws_sync._rooms
|
||||
assert ws_sync._rooms == {}
|
||||
|
||||
|
||||
# ── Limit enforcement ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("bad_id", ["abc", "x" * 65, "has space", "bad$id", "nope!"])
|
||||
def test_invalid_session_id_closed_with_policy_code(client, bad_id):
|
||||
with client.websocket_connect(f"/ws/sync/{bad_id}") as ws:
|
||||
_expect_close(ws, 1008)
|
||||
assert ws_sync._rooms == {}
|
||||
|
||||
|
||||
def test_binary_frame_closes_with_unsupported_data(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM05") as ws:
|
||||
ws.send_bytes(b"\x00\x01")
|
||||
_expect_close(ws, 1003)
|
||||
|
||||
|
||||
def test_oversized_frame_closes_sender_only(client):
|
||||
with client.websocket_connect("/ws/sync/ROOM06") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM06") as b, \
|
||||
client.websocket_connect("/ws/sync/ROOM06") as c:
|
||||
a.send_text("x" * (ws_sync.MAX_FRAME_BYTES + 1))
|
||||
_expect_close(a, 1009)
|
||||
# The room carries on without A.
|
||||
b.send_text("still-alive")
|
||||
assert c.receive_text() == "still-alive"
|
||||
|
||||
|
||||
def test_room_client_cap(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "MAX_CLIENTS_PER_ROOM", 2)
|
||||
with client.websocket_connect("/ws/sync/ROOM07") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM07") as b, \
|
||||
client.websocket_connect("/ws/sync/ROOM07") as c:
|
||||
_expect_close(c, 1013)
|
||||
a.send_text("two-is-fine")
|
||||
assert b.receive_text() == "two-is-fine"
|
||||
|
||||
|
||||
def test_total_room_cap(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "MAX_ROOMS", 1)
|
||||
with client.websocket_connect("/ws/sync/ROOM08"):
|
||||
with client.websocket_connect("/ws/sync/ROOM09") as overflow:
|
||||
_expect_close(overflow, 1013)
|
||||
# Joining the EXISTING room is still fine at the room cap.
|
||||
with client.websocket_connect("/ws/sync/ROOM08"):
|
||||
pass
|
||||
|
||||
|
||||
def test_rate_cap_closes_flooding_sender(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "RATE_BURST", 3.0)
|
||||
monkeypatch.setattr(ws_sync, "RATE_MSGS_PER_SEC", 0.0)
|
||||
with client.websocket_connect("/ws/sync/ROOM10") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM10") as b:
|
||||
for i in range(3):
|
||||
a.send_text(f"burst-{i}")
|
||||
for i in range(3):
|
||||
assert b.receive_text() == f"burst-{i}"
|
||||
a.send_text("one-too-many")
|
||||
_expect_close(a, 1008)
|
||||
# The over-limit frame was dropped, not relayed, and B lives on.
|
||||
with client.websocket_connect("/ws/sync/ROOM10") as c:
|
||||
c.send_text("fresh-socket")
|
||||
assert b.receive_text() == "fresh-socket"
|
||||
|
||||
|
||||
class _StalledPeer:
|
||||
"""A fake room member whose send never completes (peer stopped draining)."""
|
||||
|
||||
async def send_text(self, text):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def test_stalled_peer_is_evicted_and_healthy_peers_still_receive(client, monkeypatch):
|
||||
monkeypatch.setattr(ws_sync, "SEND_TIMEOUT_SECONDS", 0.2)
|
||||
with client.websocket_connect("/ws/sync/ROOM11") as a, \
|
||||
client.websocket_connect("/ws/sync/ROOM11") as b:
|
||||
# Wait for both handlers to have registered in the room, then inject
|
||||
# the stalled peer directly (a real stalled TCP peer isn't
|
||||
# constructible under TestClient).
|
||||
deadline = time.monotonic() + 2.0
|
||||
while len(ws_sync._rooms.get("ROOM11", {})) < 2:
|
||||
assert time.monotonic() < deadline, "room never filled"
|
||||
time.sleep(0.01)
|
||||
stalled = _StalledPeer()
|
||||
ws_sync._rooms["ROOM11"][stalled] = asyncio.Lock()
|
||||
|
||||
# Healthy delivery is not blocked behind the stalled peer, and by the
|
||||
# time a second frame has round-tripped, the first fan-out's timeout
|
||||
# has fired and evicted it.
|
||||
a.send_text("f1")
|
||||
assert b.receive_text() == "f1"
|
||||
a.send_text("f2")
|
||||
assert b.receive_text() == "f2"
|
||||
assert stalled not in ws_sync._rooms["ROOM11"]
|
||||
|
||||
|
||||
def test_main_run_caps_uvicorn_ws_max_size():
|
||||
"""main.py must bound inbound WS frames at the transport (uvicorn defaults
|
||||
to 16 MB, which would let a client materialize frames far past the relay's
|
||||
16 KB application cap before the handler ever sees them)."""
|
||||
import unittest.mock
|
||||
|
||||
import main
|
||||
|
||||
with (
|
||||
unittest.mock.patch("logging_setup.configure_logging"),
|
||||
unittest.mock.patch("uvicorn.run") as mock_run,
|
||||
):
|
||||
main.run()
|
||||
|
||||
kwargs = mock_run.call_args.kwargs
|
||||
assert kwargs.get("ws_max_size") == 64 * 1024
|
||||
assert kwargs["ws_max_size"] >= ws_sync.MAX_FRAME_BYTES
|
||||
|
||||
|
||||
# ── Real-app integration ─────────────────────────────────────────────────────
|
||||
|
||||
def test_route_mounted_on_real_server(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
|
||||
with TestClient(server.app) as client:
|
||||
with client.websocket_connect("/ws/sync/REALAPP") as a, \
|
||||
client.websocket_connect("/ws/sync/REALAPP") as b:
|
||||
a.send_text('{"type":"time","t":0}')
|
||||
assert b.receive_text() == '{"type":"time","t":0}'
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build & publish opt-in content packs (career venue media, rig VST slices).
|
||||
|
||||
Flat-zips a pack directory, sha256s it, and emits the ``{url, sha256, bytes}``
|
||||
block that the career and rig_builder download paths consume
|
||||
(``plugins/career/routes.py`` ``_download_pack``). Two modes:
|
||||
|
||||
--local <dir> write zips + a file:// manifest (dev/CI/tests; no network)
|
||||
--publish create/upload each pack's per-pack release; emit release URLs
|
||||
|
||||
The zip is flat (files at the archive root) to satisfy career's zip-slip guard
|
||||
(``PACK_FILENAME_RE``) and ``_validate_pack_dir``. This module is the reusable
|
||||
core the content-packs CI workflow calls, so building packs is automation —
|
||||
never a person's manual job.
|
||||
|
||||
Run ``python tools/content_packs.py --selfcheck`` for the built-in round-trip.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = "got-feedBack/feedBack" # where the content-packs release lives (public)
|
||||
|
||||
# Must mirror career's download-time whitelist (plugins/career/routes.py
|
||||
# PACK_FILENAME_RE). If the builder packs a name the downloader rejects (e.g. a
|
||||
# stray .DS_Store), the published pack fails _validate_pack_dir for every client.
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
|
||||
|
||||
def build_pack(src_dir: Path, out_zip: Path) -> dict:
|
||||
"""Flat-zip every file directly under src_dir; return {sha256, bytes}.
|
||||
|
||||
Only regular files at the top level are included (venue packs are flat).
|
||||
Subdirectories are skipped — a nested tree would trip career's zip-slip
|
||||
guard on download anyway.
|
||||
|
||||
The build is REPRODUCIBLE: identical file contents always yield a
|
||||
byte-identical zip (fixed name order, fixed mtime, fixed permissions,
|
||||
ZIP_STORED). So a sha256 computed on any machine matches the zip the CI
|
||||
workflow or another contributor produces — anyone can precompute the
|
||||
manifest values without having to be the one who uploads the asset.
|
||||
"""
|
||||
files = sorted((p for p in src_dir.iterdir() if p.is_file()),
|
||||
key=lambda p: p.name)
|
||||
if not files:
|
||||
raise ValueError(f"no files to pack in {src_dir}")
|
||||
bad = [p.name for p in files if not PACK_FILENAME_RE.fullmatch(p.name)]
|
||||
if bad:
|
||||
raise ValueError(
|
||||
f"{src_dir}: files the downloader will reject: {bad} "
|
||||
f"(allowed: {PACK_FILENAME_RE.pattern})")
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
||||
# ZIP_STORED: the media (mp4/mp3) and .vst3 binaries are already
|
||||
# compressed; deflating just burns CPU for ~0 gain.
|
||||
for p in files:
|
||||
# Fixed mtime (the zip epoch, 1980-01-01) + fixed perms so the
|
||||
# bytes don't depend on the checkout's file timestamps.
|
||||
info = zipfile.ZipInfo(p.name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
# Pin create_system: ZipInfo defaults it from the host OS (0 on
|
||||
# Windows, 3 on Unix), which would otherwise make the same pack
|
||||
# hash differently across runners. 3 = Unix.
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o644 << 16
|
||||
zf.writestr(info, p.read_bytes())
|
||||
data = out_zip.read_bytes()
|
||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
||||
|
||||
|
||||
# rig_builder ships "fat" .vst3 bundles carrying all three platforms inside
|
||||
# Contents/. A pack for one platform keeps that platform's binary dir + the
|
||||
# shared bundle files, and drops the other two.
|
||||
VST_PLATFORM_DIRS = {"mac": "MacOS", "win": "x86_64-win", "linux": "x86_64-linux"}
|
||||
|
||||
|
||||
def build_vst_pack(vst_root: Path, out_zip: Path, platform: str) -> dict:
|
||||
"""Reproducibly zip the VST tree keeping only `platform`'s binaries.
|
||||
|
||||
Slices each fat .vst3: everything is kept except the two foreign platform
|
||||
dirs (MacOS / x86_64-win / x86_64-linux) and the vst/src build trees. Arc
|
||||
names are relative to vst_root so the download endpoint extracts straight
|
||||
into <plugin>/vst/. Same reproducible-build guarantees as build_pack.
|
||||
"""
|
||||
if platform not in VST_PLATFORM_DIRS:
|
||||
raise ValueError(f"unknown platform {platform!r} (want mac/win/linux)")
|
||||
foreign = set(VST_PLATFORM_DIRS.values()) - {VST_PLATFORM_DIRS[platform]}
|
||||
files = []
|
||||
for p in sorted(vst_root.rglob("*"), key=lambda q: q.as_posix()):
|
||||
if not p.is_file():
|
||||
continue
|
||||
rel = p.relative_to(vst_root)
|
||||
if rel.parts and rel.parts[0] == "src": # skip C++/JUCE build trees
|
||||
continue
|
||||
if set(rel.parts) & foreign: # drop foreign-platform binaries
|
||||
continue
|
||||
files.append((p, rel))
|
||||
if not files:
|
||||
raise ValueError(f"no VST files to pack for {platform} in {vst_root}")
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
||||
for p, rel in files:
|
||||
info = zipfile.ZipInfo(rel.as_posix(), date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
# Pin create_system like build_pack: ZipInfo defaults it from the
|
||||
# host OS (0 on Windows, 3 on Unix), which would otherwise make the
|
||||
# same pack hash differently across runners. VST packs are the most
|
||||
# likely to be built on Windows (native .vst3), so without this pin
|
||||
# the precomputable-hash guarantee breaks exactly where it's needed.
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o644 << 16
|
||||
zf.writestr(info, p.read_bytes())
|
||||
data = out_zip.read_bytes()
|
||||
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
||||
|
||||
|
||||
def manifest_entry(out_zip: Path, url: str) -> dict:
|
||||
"""Pack info as the download-path expects it: {url, sha256, bytes}."""
|
||||
return {"url": url,
|
||||
"sha256": hashlib.sha256(out_zip.read_bytes()).hexdigest(),
|
||||
"bytes": out_zip.stat().st_size}
|
||||
|
||||
|
||||
# Per-pack, versioned, immutable release convention (matches what the team
|
||||
# already published, e.g. tag `venue-arena-v1` / asset `arena-pack-v1.zip`).
|
||||
def pack_tag(pack_id: str, version: int) -> str:
|
||||
return f"venue-{pack_id}-v{version}"
|
||||
|
||||
|
||||
def pack_asset(pack_id: str, version: int) -> str:
|
||||
return f"{pack_id}-pack-v{version}.zip"
|
||||
|
||||
|
||||
def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
|
||||
return (f"https://github.com/{repo}/releases/download/"
|
||||
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
|
||||
|
||||
|
||||
# VST packs use the same immutable per-pack convention, keyed by platform:
|
||||
# tag `vst-<plat>-v<N>`, asset `vst-<plat>-pack-v<N>.zip`. The manifest they
|
||||
# emit is keyed by platform (mac/win/linux) — the shape the rig_builder plugin's
|
||||
# data/vst_packs.json consumes.
|
||||
def vst_tag(platform: str, version: int) -> str:
|
||||
return f"vst-{platform}-v{version}"
|
||||
|
||||
|
||||
def vst_asset(platform: str, version: int) -> str:
|
||||
return f"vst-{platform}-pack-v{version}.zip"
|
||||
|
||||
|
||||
def vst_url(platform: str, version: int, repo: str = REPO) -> str:
|
||||
return (f"https://github.com/{repo}/releases/download/"
|
||||
f"{vst_tag(platform, version)}/{vst_asset(platform, version)}")
|
||||
|
||||
|
||||
def _publish_release(tag: str, zip_path: Path, title: str, notes: str,
|
||||
repo: str = REPO) -> None:
|
||||
"""Create the per-pack release if missing, then upload the versioned zip.
|
||||
|
||||
Tags are immutable: a media change means a new version (v1 → v2), never a
|
||||
re-upload — so no --clobber. gh errors if the asset already exists, which is
|
||||
the right guard against overwriting a published, referenced pack.
|
||||
"""
|
||||
if subprocess.run(["gh", "release", "view", tag, "--repo", repo],
|
||||
capture_output=True).returncode != 0:
|
||||
subprocess.run(
|
||||
["gh", "release", "create", tag, "--repo", repo, "--latest=false",
|
||||
"--title", title, "--notes", notes],
|
||||
check=True)
|
||||
subprocess.run(
|
||||
["gh", "release", "upload", tag, str(zip_path), "--repo", repo], check=True)
|
||||
|
||||
|
||||
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
|
||||
_publish_release(pack_tag(pack_id, version), zip_path,
|
||||
f"{pack_id.capitalize()} venue pack v{version}",
|
||||
"Opt-in career venue pack. Not a code release.", repo)
|
||||
|
||||
|
||||
def _pack_id(src_dir: Path) -> str:
|
||||
return src_dir.name
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("src", nargs="*", type=Path,
|
||||
help="pack source dirs (e.g. plugins/career/venue-packs/club)")
|
||||
ap.add_argument("--version", type=int, default=1,
|
||||
help="pack version (tag venue-<id>-v<N>); default 1")
|
||||
ap.add_argument("--local", type=Path, metavar="DIR",
|
||||
help="write zips here + a file:// manifest.json; no upload")
|
||||
ap.add_argument("--publish", action="store_true",
|
||||
help="create/upload the per-pack release; emit release URLs")
|
||||
ap.add_argument("--vst", action="store_true",
|
||||
help="slice one rig VST root (src[0]) into per-platform "
|
||||
"vst-<plat>-v<N> packs; manifest keyed by platform "
|
||||
"(the shape rig_builder's data/vst_packs.json wants)")
|
||||
ap.add_argument("--manifest", type=Path,
|
||||
help="write the {id: {url,sha256,bytes}} map here (default: stdout)")
|
||||
ap.add_argument("--selfcheck", action="store_true", help="run the round-trip demo and exit")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.selfcheck:
|
||||
return _selfcheck()
|
||||
if not args.src or (not args.local and not args.publish):
|
||||
ap.error("need one or more src dirs and either --local or --publish")
|
||||
|
||||
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
|
||||
manifest = {}
|
||||
if args.vst:
|
||||
vst_root = args.src[0]
|
||||
for plat in VST_PLATFORM_DIRS:
|
||||
zip_path = out_dir / vst_asset(plat, args.version)
|
||||
build_vst_pack(vst_root, zip_path, plat)
|
||||
if args.publish:
|
||||
_publish_release(vst_tag(plat, args.version), zip_path,
|
||||
f"Rig VST pack ({plat}) v{args.version}",
|
||||
"Opt-in per-platform rig VST pack. Not a code release.")
|
||||
url = vst_url(plat, args.version)
|
||||
else:
|
||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
||||
manifest[plat] = manifest_entry(zip_path, url)
|
||||
else:
|
||||
for src in args.src:
|
||||
pid = _pack_id(src)
|
||||
zip_path = out_dir / pack_asset(pid, args.version)
|
||||
build_pack(src, zip_path)
|
||||
if args.publish:
|
||||
publish(pid, args.version, zip_path)
|
||||
url = pack_url(pid, args.version)
|
||||
else:
|
||||
url = (out_dir.resolve() / zip_path.name).as_uri()
|
||||
manifest[pid] = manifest_entry(zip_path, url)
|
||||
|
||||
out = json.dumps(manifest, indent=2)
|
||||
if args.manifest:
|
||||
args.manifest.write_text(out + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
def _selfcheck() -> int:
|
||||
"""Build a pack and confirm build_pack/manifest_entry agree on the digest."""
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
td = Path(td)
|
||||
src = td / "bar"
|
||||
src.mkdir()
|
||||
(src / "manifest.json").write_text('{"venue":"bar"}')
|
||||
(src / "bored.mp4").write_bytes(b"\x00fake-video")
|
||||
zip_path = td / pack_asset("bar", 1)
|
||||
info = build_pack(src, zip_path)
|
||||
# Reproducible: a second build (into a different path) is byte-identical.
|
||||
info2 = build_pack(src, td / "again.zip")
|
||||
assert info2["sha256"] == info["sha256"], "build is not reproducible"
|
||||
entry = manifest_entry(zip_path, pack_url("bar", 1))
|
||||
assert entry["sha256"] == info["sha256"], "digest mismatch"
|
||||
assert entry["bytes"] == info["bytes"]
|
||||
assert entry["url"] == (
|
||||
f"https://github.com/{REPO}/releases/download/venue-bar-v1/bar-pack-v1.zip")
|
||||
# Round-trip: the zip must be flat (names == basenames).
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
assert set(names) == {"manifest.json", "bored.mp4"}, names
|
||||
|
||||
# VST slice: keep target platform + shared, drop foreign, reproducible.
|
||||
c = td / "vst" / "Foo.vst3" / "Contents"
|
||||
for d in ("MacOS", "x86_64-win", "x86_64-linux", "Resources"):
|
||||
(c / d).mkdir(parents=True)
|
||||
(c / "MacOS" / "Foo").write_bytes(b"mac")
|
||||
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux")
|
||||
(c / "Info.plist").write_bytes(b"<plist/>")
|
||||
vzip = td / vst_asset("linux", 1)
|
||||
vinfo = build_vst_pack(td / "vst", vzip, "linux")
|
||||
assert vinfo == build_vst_pack(td / "vst", td / "v2.zip", "linux"), \
|
||||
"vst slice is not reproducible"
|
||||
with zipfile.ZipFile(vzip) as zf:
|
||||
vnames = set(zf.namelist())
|
||||
assert "Foo.vst3/Contents/x86_64-linux/Foo.so" in vnames
|
||||
assert "Foo.vst3/Contents/Info.plist" in vnames
|
||||
assert not any("MacOS" in n for n in vnames), vnames
|
||||
print("content_packs selfcheck: ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user