mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 23:58:31 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adaf3e9f72 | ||
|
|
e14ef64224 | ||
|
|
365cec1d29 | ||
|
|
1702afa379 | ||
|
|
917d81c2d2 | ||
|
|
939c98214b | ||
|
|
4e0e3c5417 | ||
|
|
8ef97708ef | ||
|
|
e729c44d5b |
@@ -46,6 +46,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
carry their gig log; instruments their gig count.
|
carry their gig log; instruments their gig count.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
|
||||||
|
list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS
|
||||||
|
`ready` sends. The stems plugin could only learn it from that WS message, which
|
||||||
|
arrives once the highway is already on screen — so it decoded and then copied the
|
||||||
|
whole song's PCM to its audio worklet with the player visible: over half a gigabyte
|
||||||
|
of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the
|
||||||
|
song-credits card appeared. With the list available at `song:loading` the plugin
|
||||||
|
does all of it before the highway is drawn. Built by calling `load_song` itself, so
|
||||||
|
it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay
|
||||||
|
nothing.
|
||||||
|
- **Folder library renders only the songs on screen** (#965) — a song list used to
|
||||||
|
render *every* song it held. On a flat 50,944-song library that was one `<div>`
|
||||||
|
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
|
||||||
|
built even while another screen was showing. A document that size also punishes
|
||||||
|
unrelated code: any `document.querySelector` that misses has to walk the whole
|
||||||
|
tree — which is how the song-preview menu check ended up eating ~50% of the
|
||||||
|
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
|
||||||
|
windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged.
|
||||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||||
|
|||||||
+69
-5
@@ -829,9 +829,60 @@ def post_song_gap_fill(filename: str, data: dict):
|
|||||||
return {"ok": True, "written": additions, "skipped": skipped}
|
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"]}
|
||||||
|
for s in loaded.stems
|
||||||
|
],
|
||||||
|
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/song/{filename:path}")
|
@router.get("/api/song/{filename:path}")
|
||||||
async def get_song_info(filename: str):
|
async def get_song_info(filename: str, stems: int = 0):
|
||||||
"""Return song metadata, from cache or by extracting it from the song source."""
|
"""Return song metadata, from cache or by extracting it from the song source.
|
||||||
|
|
||||||
|
`?stems=1` additionally returns the playable stem list with URLs, so the
|
||||||
|
stems plugin can start fetching/decoding on `song:loading` instead of waiting
|
||||||
|
for the highway's WS `ready` (see _playable_stems_payload).
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
dlc = _get_dlc_dir()
|
dlc = _get_dlc_dir()
|
||||||
if not dlc:
|
if not dlc:
|
||||||
@@ -854,8 +905,21 @@ async def get_song_info(filename: str):
|
|||||||
|
|
||||||
mtime, size = appstate.stat_for_cache(song_path)
|
mtime, size = appstate.stat_for_cache(song_path)
|
||||||
cached = appstate.meta_db.get(cache_key, mtime, size)
|
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:
|
if cached:
|
||||||
return cached
|
return await _with_stems(cached)
|
||||||
|
|
||||||
# Extract in thread pool
|
# Extract in thread pool
|
||||||
def _extract():
|
def _extract():
|
||||||
@@ -863,5 +927,5 @@ async def get_song_info(filename: str):
|
|||||||
appstate.meta_db.put(cache_key, mtime, size, meta)
|
appstate.meta_db.put(cache_key, mtime, size, meta)
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
|
meta = await loop.run_in_executor(None, _extract)
|
||||||
return meta
|
return await _with_stems(meta)
|
||||||
|
|||||||
+19
-6
@@ -80,6 +80,20 @@ def find_full_mix(stems: list[dict]) -> dict | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stem_default_on(raw) -> bool:
|
||||||
|
"""Whether a manifest stem entry plays by default.
|
||||||
|
|
||||||
|
Absent means on. A string is honoured so a hand-written manifest can say
|
||||||
|
`default: off`. Extracted so the WS `ready` payload and the REST song-info
|
||||||
|
payload cannot drift: the stems plugin now preloads from REST and then has
|
||||||
|
to agree with what the WS says a moment later, or it would rebuild the whole
|
||||||
|
graph for nothing.
|
||||||
|
"""
|
||||||
|
if isinstance(raw, str):
|
||||||
|
return raw.lower() not in ("off", "false", "0", "no")
|
||||||
|
return bool(raw)
|
||||||
|
|
||||||
|
|
||||||
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||||
|
|
||||||
@@ -1100,12 +1114,11 @@ def load_song(
|
|||||||
sfile = str(s.get("file", ""))
|
sfile = str(s.get("file", ""))
|
||||||
if not sid or not sfile:
|
if not sid or not sfile:
|
||||||
continue
|
continue
|
||||||
default_val = s.get("default", True)
|
stems.append({
|
||||||
if isinstance(default_val, str):
|
"id": sid,
|
||||||
default_on = default_val.lower() not in ("off", "false", "0", "no")
|
"file": sfile,
|
||||||
else:
|
"default": stem_default_on(s.get("default", True)),
|
||||||
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
|
# 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
|
# it out so that no consumer of `stems` — the mixer, the library's stem
|
||||||
|
|||||||
+90
-17
@@ -46,6 +46,8 @@ from pathlib import Path
|
|||||||
from fastapi import Body, HTTPException
|
from fastapi import Body, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
import sloppak
|
||||||
|
from dlc_paths import _resolve_dlc_path
|
||||||
from progression import instrument_for_arrangement
|
from progression import instrument_for_arrangement
|
||||||
|
|
||||||
PLUGIN_ID = "career"
|
PLUGIN_ID = "career"
|
||||||
@@ -53,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
|||||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||||
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||||
DOWNLOAD_CHUNK = 1024 * 256
|
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()
|
_lock = threading.Lock()
|
||||||
_state = {
|
_state = {
|
||||||
@@ -517,27 +522,39 @@ def _current_venue():
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
def _fill_genre_songs(gkey, exclude, limit):
|
||||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||||
still gets a full set (playing them is how stubs start).
|
set hasn't already picked.
|
||||||
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."""
|
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
|
||||||
|
That restriction created a hole: a song you'd played on a DIFFERENT
|
||||||
|
instrument's arrangement has a stats row, so it was excluded here — and it
|
||||||
|
lives in the played bucket for THAT instrument, not this passport's, so it
|
||||||
|
was excluded there too. It could never be gigged. A player with 137 metalcore
|
||||||
|
songs, all played on another instrument, got a 404 (reproduced). The player's
|
||||||
|
library is the pool; whether a song has stats on some other instrument has no
|
||||||
|
bearing on whether it can be in THIS gig.
|
||||||
|
|
||||||
|
Shuffled, so re-roll actually changes the set. The old version returned the
|
||||||
|
library's first N in table order every time, so re-roll was a no-op for any
|
||||||
|
set drawn from the filler (reproduced).
|
||||||
|
|
||||||
|
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
|
||||||
|
songs, single-user); push into SQL if propose ever feels slow.
|
||||||
|
"""
|
||||||
db = _state["meta_db"]
|
db = _state["meta_db"]
|
||||||
if db is None:
|
if db is None:
|
||||||
return []
|
return []
|
||||||
rows = db.conn.execute(
|
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()
|
).fetchall()
|
||||||
out = []
|
pool = [
|
||||||
for filename, title, artist, genre in rows:
|
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||||
if _genre_key(genre) != gkey or filename in exclude:
|
for filename, title, artist, genre in rows
|
||||||
continue
|
if _genre_key(genre) == gkey and filename not in exclude
|
||||||
out.append({"filename": filename, "title": title or filename,
|
]
|
||||||
"artist": artist or ""})
|
random.shuffle(pool) # re-roll must vary; free per call
|
||||||
if len(out) >= limit:
|
return pool[:limit]
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
@@ -734,6 +751,62 @@ def setup(app, context):
|
|||||||
"snapshot": snapshot})
|
"snapshot": snapshot})
|
||||||
return {"ok": True}
|
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")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||||
def propose_gig(body: dict = Body(...)):
|
def propose_gig(body: dict = Body(...)):
|
||||||
inst = str((body or {}).get("instrument") or "")
|
inst = str((body or {}).get("instrument") or "")
|
||||||
@@ -775,7 +848,7 @@ def setup(app, context):
|
|||||||
picks.append(s)
|
picks.append(s)
|
||||||
if len(picks) < size:
|
if len(picks) < size:
|
||||||
exclude = {s["filename"] for s in picks}
|
exclude = {s["filename"] for s in picks}
|
||||||
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
|
||||||
if not picks:
|
if not picks:
|
||||||
raise HTTPException(404, "No songs of this genre in the library.")
|
raise HTTPException(404, "No songs of this genre in the library.")
|
||||||
venue = _current_venue()
|
venue = _current_venue()
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const API = '/api/plugins/career';
|
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 VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||||
const NO_VENUE = '__none__';
|
const NO_VENUE = '__none__';
|
||||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||||
@@ -1123,10 +1127,52 @@
|
|||||||
sfx('page');
|
sfx('page');
|
||||||
}
|
}
|
||||||
|
|
||||||
function startGig() {
|
// Unpack the whole set before the first note.
|
||||||
|
//
|
||||||
|
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
||||||
|
// a set that cost landed BETWEEN songs: the player finished a number and
|
||||||
|
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
|
||||||
|
// known up front, so warm it all while the poster is still on screen.
|
||||||
|
//
|
||||||
|
// Best-effort by design: a library that won't pre-extract must not stop the
|
||||||
|
// gig from starting — the play itself surfaces the error the same way it
|
||||||
|
// does outside a gig. Slow is better than blocked.
|
||||||
|
async function prepareGigSongs(prop, btn) {
|
||||||
|
const label = btn && btn.textContent;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
|
||||||
|
// A bare `await fetch(...)` only rejects on a network ERROR — a server
|
||||||
|
// that accepts the connection and then never answers hangs forever, and
|
||||||
|
// the gig would never start. That would make this optimisation the very
|
||||||
|
// thing it promises never to be: the reason you cannot play. Give up
|
||||||
|
// waiting and let the first play extract lazily, exactly as before.
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
await fetch(`${API}/gigs/prepare`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// abort, offline, non-2xx — all the same: start the gig anyway.
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGig(btn) {
|
||||||
const prop = _ppGigProposal;
|
const prop = _ppGigProposal;
|
||||||
const q = window.feedBack && window.feedBack.playQueue;
|
const q = window.feedBack && window.feedBack.playQueue;
|
||||||
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
|
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 gig BORROWS the stage: stash whatever venue/viz the user had so
|
||||||
// the set ending gives it back (unlike "Play here", which is an
|
// the set ending gives it back (unlike "Play here", which is an
|
||||||
// explicit persistent choice on the venue card).
|
// explicit persistent choice on the venue card).
|
||||||
@@ -1146,7 +1192,21 @@
|
|||||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||||
} catch (_) { /* viz optional — restore stays intact */ }
|
} 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;
|
_appliedManifestVenue = null;
|
||||||
|
if (_state) pushCrowdManifest(_state);
|
||||||
_ppGigRun = {
|
_ppGigRun = {
|
||||||
songs: prop.songs,
|
songs: prop.songs,
|
||||||
venue_id: prop.venue_id,
|
venue_id: prop.venue_id,
|
||||||
@@ -1424,7 +1484,7 @@
|
|||||||
}
|
}
|
||||||
const gigBtn = e.target.closest('[data-pp-gig]');
|
const gigBtn = e.target.closest('[data-pp-gig]');
|
||||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
|
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
||||||
if (e.target.closest('[data-pp-gig-reroll]')) {
|
if (e.target.closest('[data-pp-gig-reroll]')) {
|
||||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -878,6 +878,146 @@ function createFolderSurface(cfg) {
|
|||||||
var _dragRafId = null;
|
var _dragRafId = null;
|
||||||
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
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() {
|
function _getScrollEl() {
|
||||||
var el = _treeEl();
|
var el = _treeEl();
|
||||||
while (el && el !== document.documentElement) {
|
while (el && el !== document.documentElement) {
|
||||||
@@ -1159,8 +1299,8 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
var _listPopulated = open;
|
var _listPopulated = open;
|
||||||
function _populateList() {
|
function _populateList() {
|
||||||
_sortSongs(folder.songs).forEach(function (s) {
|
_fillSongList(list, folder.songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||||
});
|
});
|
||||||
(folder.children || []).forEach(function (child) {
|
(folder.children || []).forEach(function (child) {
|
||||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||||
@@ -1195,12 +1335,18 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
var nowOpen = content.style.display === 'none';
|
var nowOpen = content.style.display === 'none';
|
||||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
// Show BEFORE populating: a windowed list measures a real row and the
|
||||||
|
// scroller viewport, and both are zero while display:none.
|
||||||
content.style.display = nowOpen ? '' : 'none';
|
content.style.display = nowOpen ? '' : 'none';
|
||||||
|
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||||
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
||||||
if (nowOpen) _openFolders.add(folder.path);
|
if (nowOpen) _openFolders.add(folder.path);
|
||||||
else _openFolders.delete(folder.path);
|
else _openFolders.delete(folder.path);
|
||||||
_storeJSON('open', [..._openFolders]);
|
_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);
|
wrap.appendChild(hdr); wrap.appendChild(content);
|
||||||
@@ -1245,8 +1391,8 @@ function createFolderSurface(cfg) {
|
|||||||
}
|
}
|
||||||
var _populated = _unsortedOpen;
|
var _populated = _unsortedOpen;
|
||||||
function _populate() {
|
function _populate() {
|
||||||
_sortSongs(songs).forEach(function (s) {
|
_fillSongList(list, songs, function (s) {
|
||||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||||
@@ -1255,10 +1401,12 @@ function createFolderSurface(cfg) {
|
|||||||
hdr.addEventListener('click', function () {
|
hdr.addEventListener('click', function () {
|
||||||
if (_query()) return;
|
if (_query()) return;
|
||||||
_unsortedOpen = list.style.display === 'none';
|
_unsortedOpen = list.style.display === 'none';
|
||||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
// Show BEFORE populating — see the folder toggle above.
|
||||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||||
|
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||||
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
||||||
_store(cfg.unsortedKey, String(_unsortedOpen));
|
_store(cfg.unsortedKey, String(_unsortedOpen));
|
||||||
|
_repaintVirtualLists(); // this toggle moved every list below it
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.appendChild(hdr); wrap.appendChild(list);
|
wrap.appendChild(hdr); wrap.appendChild(list);
|
||||||
@@ -1340,6 +1488,10 @@ function createFolderSurface(cfg) {
|
|||||||
// ── Render ──────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────
|
||||||
function _render() {
|
function _render() {
|
||||||
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
_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();
|
var treeEl = _treeEl();
|
||||||
if (!treeEl) return;
|
if (!treeEl) return;
|
||||||
var data = _filtered();
|
var data = _filtered();
|
||||||
@@ -1451,6 +1603,7 @@ function createFolderSurface(cfg) {
|
|||||||
|
|
||||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||||
function _unload() {
|
function _unload() {
|
||||||
|
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||||
if (!cfg.searchInputId) return;
|
if (!cfg.searchInputId) return;
|
||||||
var el = _el(cfg.searchInputId);
|
var el = _el(cfg.searchInputId);
|
||||||
if (el) el.style.maxWidth = '';
|
if (el) el.style.maxWidth = '';
|
||||||
@@ -1554,6 +1707,8 @@ function createFolderSurface(cfg) {
|
|||||||
init: _init,
|
init: _init,
|
||||||
onScreenChanged: _onScreenChanged,
|
onScreenChanged: _onScreenChanged,
|
||||||
render: _render,
|
render: _render,
|
||||||
|
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||||
|
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,6 +1811,7 @@ if (!window.__folderLibraryLib) {
|
|||||||
window.folderLibrary = {
|
window.folderLibrary = {
|
||||||
load: function (force) { return _lib.load(force); },
|
load: function (force) { return _lib.load(force); },
|
||||||
unload: function () { _lib.unload(); },
|
unload: function () { _lib.unload(); },
|
||||||
|
__test: _lib.__test,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-load if folder view was already active when this script was injected.
|
// Auto-load if folder view was already active when this script was injected.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Windowed song lists (feedBack#965).
|
||||||
|
//
|
||||||
|
// A song list used to render EVERY song. On a flat 50,944-song library that is
|
||||||
|
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
|
||||||
|
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
|
||||||
|
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
|
||||||
|
// the app had to walk that whole tree.
|
||||||
|
//
|
||||||
|
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
|
||||||
|
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
|
||||||
|
// place, so it is tested directly — the DOM glue around it is not the risky bit.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
const window = {
|
||||||
|
console,
|
||||||
|
document: {
|
||||||
|
readyState: 'complete',
|
||||||
|
addEventListener() {},
|
||||||
|
getElementById() { return null; },
|
||||||
|
querySelector() { return null; },
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
localStorage: { getItem() { return null; }, setItem() {} },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
setInterval() { return 0; },
|
||||||
|
clearInterval() {},
|
||||||
|
requestAnimationFrame() { return 0; },
|
||||||
|
cancelAnimationFrame() {},
|
||||||
|
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||||
|
innerHeight: 800,
|
||||||
|
};
|
||||||
|
window.window = window;
|
||||||
|
window.globalThis = window;
|
||||||
|
const ctx = vm.createContext(window);
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||||
|
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||||
|
return window.folderLibrary.__test;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
|
||||||
|
|
||||||
|
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
|
||||||
|
const ROW = 44;
|
||||||
|
const VH = 800;
|
||||||
|
const TOTAL = 50938;
|
||||||
|
|
||||||
|
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
const rendered = w.end - w.start;
|
||||||
|
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
|
||||||
|
// ~18 rows fit in 800px, plus buffer above and below.
|
||||||
|
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
|
||||||
|
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, TOTAL - w.end);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
|
||||||
|
const scrolled = 10000 * ROW; // row 10,000 at the fold
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
|
||||||
|
assert.ok(w.end > w.start);
|
||||||
|
// The invariant that keeps the scrollbar honest: padding rows + rendered
|
||||||
|
// rows must account for every song, or the list changes height as you scroll.
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at the very bottom: no bottom padding, end lands on the last song', () => {
|
||||||
|
const rows = TOTAL;
|
||||||
|
const scrolled = rows * ROW - VH; // scrolled to the end
|
||||||
|
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grid view: perRow songs collapse into one row', () => {
|
||||||
|
const perRow = 6;
|
||||||
|
const rows = Math.ceil(TOTAL / perRow);
|
||||||
|
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
|
||||||
|
assert.ok(w.end <= TOTAL);
|
||||||
|
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled far past the list: keeps one row, never a negative window', () => {
|
||||||
|
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
|
||||||
|
assert.ok(w.end > w.start, 'window must never invert');
|
||||||
|
assert.ok(w.start >= 0 && w.end <= TOTAL);
|
||||||
|
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
|
||||||
|
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.ok(w.end > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
|
||||||
|
// Measured height of 0 (e.g. list still display:none) must not divide by zero
|
||||||
|
// and must not silently render an empty list.
|
||||||
|
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
|
||||||
|
assert.equal(w.start, 0);
|
||||||
|
assert.equal(w.end, TOTAL);
|
||||||
|
assert.equal(w.padRowsTop, 0);
|
||||||
|
assert.equal(w.padRowsBottom, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('small lists are below the virtualization threshold', () => {
|
||||||
|
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
|
||||||
|
// perRow and rows were originally captured once at fill time. paint() also runs
|
||||||
|
// on resize, so a narrower/wider window changed the column count while the
|
||||||
|
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
|
||||||
|
// the padding. These pin that the geometry is a function of perRow, so a stale
|
||||||
|
// perRow cannot silently survive.
|
||||||
|
|
||||||
|
test('resizing the grid to fewer columns re-windows against the new row count', () => {
|
||||||
|
const total = 10000;
|
||||||
|
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
|
||||||
|
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
|
||||||
|
|
||||||
|
// Same viewport, half the columns -> about half as many songs on screen.
|
||||||
|
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
|
||||||
|
// ...and the total must still add up, or the scrollbar lies after a resize.
|
||||||
|
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
|
||||||
|
const rows = Math.ceil(total / perRow);
|
||||||
|
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
|
||||||
|
`rows must account for every song at perRow=${perRow}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale perRow would break the total-height invariant (the bug)', () => {
|
||||||
|
const total = 10000;
|
||||||
|
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
|
||||||
|
// the row count no longer matches the geometry, and the padding is wrong.
|
||||||
|
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
|
||||||
|
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
|
||||||
|
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
|
||||||
|
assert.notEqual(accounted, actualRows,
|
||||||
|
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
|
||||||
|
'metrics() recomputes both together on every paint so this cannot happen in practice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolled grid window always starts on a row boundary', () => {
|
||||||
|
const total = 10000, perRow = 4;
|
||||||
|
const rows = Math.ceil(total / perRow);
|
||||||
|
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
|
||||||
|
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
|
||||||
|
});
|
||||||
@@ -15388,6 +15388,41 @@
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The host throttles paused frames to ~10 fps, on the assumption
|
||||||
|
// that a paused chart is a static picture and re-rendering it is
|
||||||
|
// pure waste (highway-constants._PAUSED_FRAME_INTERVAL_MS).
|
||||||
|
//
|
||||||
|
// That stopped being true when the venue landed. The venue backdrop
|
||||||
|
// is a PLAYING VIDEO and the crowd reacts on its own clock, and they
|
||||||
|
// are drawn into this same canvas as the highway — so throttling the
|
||||||
|
// highway throttled the whole room. Pausing the song dropped the
|
||||||
|
// venue, the crowd and the stage to 10 fps.
|
||||||
|
//
|
||||||
|
// Two independent sources of motion, and BOTH must keep their frames:
|
||||||
|
//
|
||||||
|
// • a crowd video rolling on its own clock (career venue pack), and
|
||||||
|
// • the venue scene's own fake-depth motion — the backdrop breathes,
|
||||||
|
// the haze drifts, warmth pulses, the shimmer moves. That is
|
||||||
|
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
|
||||||
|
// so it only moves while we are actually given frames, and it runs
|
||||||
|
// with NO pack at all.
|
||||||
|
//
|
||||||
|
// The throttle fires whenever the CHART CLOCK is stalled — which is
|
||||||
|
// not just a pause. A count-in and the credits/author overlay stall it
|
||||||
|
// exactly the same way, so the venue was stuttering there too.
|
||||||
|
//
|
||||||
|
// With no venue at all (plain 3D highway) the paused scene really is a
|
||||||
|
// still picture: motion mode reads 'off', we claim nothing, and the
|
||||||
|
// throttle still saves the GPU as #654 intended.
|
||||||
|
needsContinuousFrames() {
|
||||||
|
if (!_isReady || _ctxLost) return false;
|
||||||
|
for (const v of _venueCrowdVideos) {
|
||||||
|
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
|
||||||
|
}
|
||||||
|
// 'off' also covers prefers-reduced-motion and "no venue scene".
|
||||||
|
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
|
||||||
|
},
|
||||||
|
|
||||||
draw(bundle) {
|
draw(bundle) {
|
||||||
if (!_isReady) return;
|
if (!_isReady) return;
|
||||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||||
|
|||||||
+23
-1
@@ -1334,12 +1334,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
|||||||
// leaving the player still leaves — and abandons the queue.
|
// leaving the player still leaves — and abandons the queue.
|
||||||
window.feedBack.playQueue = (function () {
|
window.feedBack.playQueue = (function () {
|
||||||
let list = [], idx = -1, source = '', arrangements = null;
|
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 active = () => idx >= 0 && idx < list.length;
|
||||||
const hasNext = () => active() && idx < list.length - 1;
|
const hasNext = () => active() && idx < list.length - 1;
|
||||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||||
function _play(i) {
|
function _play(i) {
|
||||||
const fn = list[i];
|
const fn = list[i];
|
||||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
|
||||||
|
// that survives wrapper chains dropping the options arg. Both set; either
|
||||||
|
// suffices. playSong runs its clear-guard synchronously at entry, and the
|
||||||
|
// wrapper chain reaches it synchronously, so the flag is still set then.
|
||||||
|
_internalPlay = true;
|
||||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||||
}
|
}
|
||||||
function start(files, opts) {
|
function start(files, opts) {
|
||||||
@@ -1371,6 +1384,15 @@ window.feedBack.playQueue = (function () {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
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; },
|
source: function () { return source; },
|
||||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||||
// What's coming, for consumers that RENDER the queue (a results
|
// What's coming, for consumers that RENDER the queue (a results
|
||||||
|
|||||||
+36
-1
@@ -986,6 +986,22 @@ function createHighway() {
|
|||||||
// inline arrow function.
|
// inline arrow function.
|
||||||
function _handleAsyncInitFailure(e) {
|
function _handleAsyncInitFailure(e) {
|
||||||
if (hwState._renderer !== _installedRenderer) return;
|
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);
|
console.error('renderer async init failure:', e);
|
||||||
_destroyCurrentIfInited();
|
_destroyCurrentIfInited();
|
||||||
hwState._renderer = _defaultRenderer;
|
hwState._renderer = _defaultRenderer;
|
||||||
@@ -1159,6 +1175,17 @@ function createHighway() {
|
|||||||
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
|
' (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() {
|
function draw() {
|
||||||
hwState.animFrame = requestAnimationFrame(draw);
|
hwState.animFrame = requestAnimationFrame(draw);
|
||||||
if (!hwState.canvas || !hwState._renderer) return;
|
if (!hwState.canvas || !hwState._renderer) return;
|
||||||
@@ -1223,7 +1250,15 @@ function createHighway() {
|
|||||||
const _nowP = performance.now();
|
const _nowP = performance.now();
|
||||||
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
||||||
_paused = true;
|
_paused = true;
|
||||||
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
// ...unless the renderer says its picture is NOT static while
|
||||||
|
// paused. The throttle assumes a paused chart is a still frame,
|
||||||
|
// but a renderer can own content on a clock of its own — the 3D
|
||||||
|
// highway draws the venue's video backdrop and its reactive crowd
|
||||||
|
// into this same canvas, so throttling the highway throttled the
|
||||||
|
// whole room to 10 fps whenever the song was paused. Optional
|
||||||
|
// method: renderers that don't implement it keep the throttle.
|
||||||
|
if (!_rendererNeedsContinuousFrames()
|
||||||
|
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||||
hwState._lastPausedDrawAt = _nowP;
|
hwState._lastPausedDrawAt = _nowP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -638,9 +638,18 @@ export let artAbortController = null;
|
|||||||
export async function playSong(filename, arrangement, options) {
|
export async function playSong(filename, arrangement, options) {
|
||||||
console.log('playSong called:', filename);
|
console.log('playSong called:', filename);
|
||||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
// can't hijack the next song's end. The queue signals a play it is DRIVING
|
||||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
|
||||||
window.feedBack.playQueue.clear();
|
// band). The out-of-band one exists because plugin playSong wrappers forward
|
||||||
|
// only (filename, arrangement) and drop the options object — with just the
|
||||||
|
// in-band flag, the queue cleared itself the instant its first song played
|
||||||
|
// and a gig never advanced. Consume the flag whether or not we go on to clear,
|
||||||
|
// so it can't leak into a later manual play.
|
||||||
|
const _pq = window.feedBack && window.feedBack.playQueue;
|
||||||
|
const _queueDriven = (options && options.fromQueue)
|
||||||
|
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
|
||||||
|
if (!_queueDriven && _pq) {
|
||||||
|
_pq.clear();
|
||||||
}
|
}
|
||||||
if (!options || options.bridge !== false) {
|
if (!options || options.bridge !== false) {
|
||||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||||
|
|||||||
@@ -133,6 +133,11 @@
|
|||||||
let _lastStingerAt = -Infinity;
|
let _lastStingerAt = -Infinity;
|
||||||
let _prevStreak = 0;
|
let _prevStreak = 0;
|
||||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
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;
|
let _bound = false;
|
||||||
|
|
||||||
function now() { return Date.now(); }
|
function now() { return Date.now(); }
|
||||||
@@ -478,10 +483,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSongLoaded() {
|
// 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;
|
||||||
|
|
||||||
machine.reset();
|
machine.reset();
|
||||||
_prevStreak = 0;
|
_prevStreak = 0;
|
||||||
_lastAccuracyPct = null;
|
_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
|
// Abort any stinger/pending state from the previous song: its ended
|
||||||
// handler must not fade back into the old song's layers.
|
// handler must not fade back into the old song's layers.
|
||||||
cancelFade();
|
cancelFade();
|
||||||
@@ -494,7 +529,27 @@
|
|||||||
_loadingLoop = null;
|
_loadingLoop = null;
|
||||||
_fadingLoop = null;
|
_fadingLoop = null;
|
||||||
if (_venueActive && _manifest) {
|
if (_venueActive && _manifest) {
|
||||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +706,7 @@
|
|||||||
bindRuntime,
|
bindRuntime,
|
||||||
getState,
|
getState,
|
||||||
celebrate,
|
celebrate,
|
||||||
|
isArrangementSwitch,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (root) root.v3VenueCrowd = api;
|
if (root) root.v3VenueCrowd = api;
|
||||||
|
|||||||
@@ -18,6 +18,30 @@
|
|||||||
let _lastMood = 'idle';
|
let _lastMood = 'idle';
|
||||||
let _bound = false;
|
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() {
|
function isVenueViz() {
|
||||||
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
||||||
const sel = root.v3VenueViz.getSelectedVizId
|
const sel = root.v3VenueViz.getSelectedVizId
|
||||||
@@ -146,7 +170,8 @@
|
|||||||
|
|
||||||
function syncViz(vizId) {
|
function syncViz(vizId) {
|
||||||
const id = String(vizId || '');
|
const id = String(vizId || '');
|
||||||
if (id === 'venue') {
|
// Venue selected is necessary but not sufficient — see shouldBeActive.
|
||||||
|
if (id === 'venue' && isPlayerScreen()) {
|
||||||
activate();
|
activate();
|
||||||
} else {
|
} else {
|
||||||
deactivate();
|
deactivate();
|
||||||
@@ -192,12 +217,19 @@
|
|||||||
if (_active) syncInstrumentPov();
|
if (_active) syncInstrumentPov();
|
||||||
});
|
});
|
||||||
sm.on('viz:renderer:ready', () => {
|
sm.on('viz:renderer:ready', () => {
|
||||||
if (isVenueViz()) activate();
|
if (shouldBeActive()) activate();
|
||||||
else deactivate();
|
else deactivate();
|
||||||
});
|
});
|
||||||
sm.on('viz:reverted', () => 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 (isVenueViz()) activate();
|
if (shouldBeActive()) activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState() {
|
function getState() {
|
||||||
@@ -234,6 +266,8 @@
|
|||||||
activate,
|
activate,
|
||||||
deactivate,
|
deactivate,
|
||||||
syncViz,
|
syncViz,
|
||||||
|
isPlayerScreen,
|
||||||
|
shouldBeActive,
|
||||||
onAssetsLoaded,
|
onAssetsLoaded,
|
||||||
onAssetsFailed,
|
onAssetsFailed,
|
||||||
onPerformanceState,
|
onPerformanceState,
|
||||||
|
|||||||
@@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
|
|||||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// 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');
|
||||||
|
});
|
||||||
@@ -77,3 +77,89 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
|||||||
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
||||||
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
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,3 +49,80 @@ test('peekNext is null after clear', () => {
|
|||||||
q.clear();
|
q.clear();
|
||||||
assert.strictEqual(q.peekNext(), null);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ test('index.html loads venue deps before venue-scene-3d', () => {
|
|||||||
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('syncViz activates only for venue visualization id', () => {
|
test('syncViz activates only for venue visualization id, and only on the player', () => {
|
||||||
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
||||||
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
||||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||||
@@ -216,7 +216,14 @@ test('syncViz activates only for venue visualization id', () => {
|
|||||||
global.v3VenueViz = venueViz;
|
global.v3VenueViz = venueViz;
|
||||||
global.v3VenueInstrumentPov = pov;
|
global.v3VenueInstrumentPov = pov;
|
||||||
global.feedBack = { on() {} };
|
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 {
|
try {
|
||||||
|
onScreen('player');
|
||||||
venueScene.deactivate();
|
venueScene.deactivate();
|
||||||
venueScene.syncViz('highway_3d');
|
venueScene.syncViz('highway_3d');
|
||||||
assert.equal(global._h3dActive, false);
|
assert.equal(global._h3dActive, false);
|
||||||
@@ -224,7 +231,16 @@ test('syncViz activates only for venue visualization id', () => {
|
|||||||
assert.equal(global._h3dActive, true);
|
assert.equal(global._h3dActive, true);
|
||||||
assert.equal(venueScene.getState().active, true);
|
assert.equal(venueScene.getState().active, true);
|
||||||
assert.equal(venueScene.getState().themeId, 'small-club');
|
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 {
|
} finally {
|
||||||
|
global.document = prevDoc;
|
||||||
venueScene.deactivate();
|
venueScene.deactivate();
|
||||||
delete global.h3dVenueSceneSetActive;
|
delete global.h3dVenueSceneSetActive;
|
||||||
delete global.h3dVenueSceneSetMood;
|
delete global.h3dVenueSceneSetMood;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// 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,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
|
|||||||
res = client.post("/api/plugins/career/drill-state",
|
res = client.post("/api/plugins/career/drill-state",
|
||||||
json={"byNode": {}, "goldImprov": blob})
|
json={"byNode": {}, "goldImprov": blob})
|
||||||
assert res.status_code == 413
|
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"
|
||||||
|
|||||||
@@ -174,3 +174,176 @@ def test_double_download_409s(client, monkeypatch):
|
|||||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||||
assert client.delete("/api/plugins/career/packs/bar").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()) == []
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""`/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"]}
|
||||||
|
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_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}
|
||||||
Reference in New Issue
Block a user