Compare commits

..
Author SHA1 Message Date
byrongamatos b9180a8182 fix(career): load the gig's venue pack when the gig starts
Tester: "Venue doesn't load when starting song from passport. Loads standard
particles."

crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage
pack — 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. startGig
set the venue override and nulled _appliedManifestVenue but never re-pushed, so
the venue visualization turned on (3D highway) while its pack never loaded — the
song played over the bare highway backdrop, or over whatever venue a previous
refresh() had left applied.

startGig now pushes the crowd manifest for the gig venue right after setting the
override, using the career state the booking screen already fetched.

This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other
caller and startGig is not it), but it is fixed by static analysis — I could not
reproduce the user-visible symptom locally because this instance happened to have
a manifest already applied from a prior refresh. On-device confirmation on a real
passport gig is still owed.

Guard test: startGig must push the manifest after setting the override (fails on
the pre-fix source). Career suite green.
2026-07-15 10:03:21 +02:00
byrongamatos 7fa60e5ef6 fix(career): a gig's song pool is the whole genre, and re-roll varies it
Two tester reports, one root: the gig song pool was built from only two sets —
songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL
(`filename NOT IN song_stats`).

A song played on a DIFFERENT instrument's arrangement is in neither: it has a
stats row (so the "unplayed" filler skipped it), and its played bucket is that
other instrument's, not this passport's. It could never be gigged.

- "Metalcore says 137 songs only shows 1 in the gig list" — a library of
  metalcore all played on another instrument. Reproduced: a guitar passport with
  137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw
  was whatever handful happened to be on-instrument or truly unplayed.

- "Passport re-roll does not change songs" — a set drawn from that filler was the
  library's first N in table ORDER, every call. Re-roll re-proposes, so it
  returned the identical set. Reproduced: 3 proposals, byte-identical.

_unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of
the genre the set hasn't already picked (a stats row on some other instrument has
no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll
actually re-rolls.

Both reproduced against the real propose logic before the fix and pinned as
regression tests (both fail on the pre-fix routes.py). Full career suite green.
2026-07-15 09:52:50 +02:00
Byron GamatosandGitHub 1702afa379 feat(career): extract the whole setlist before the gig starts (no more waiting between songs) (#971)
ship-ci / ci (push) Waiting to run
* feat(career): extract the whole setlist before the gig starts

A feedpak is a zip, and 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 there waiting for the next one to unpack, mid-gig.

A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.

Best-effort by design, at every level:
  - a corrupt pak in the set does not sink the prepare (it is reported in
    `failed`; the play itself surfaces the error exactly as it does outside a
    gig — slow beats blocked)
  - a host without the library resolvers degrades to a no-op rather than 500
  - a failed request just falls through to the old lazy extraction

Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.

Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.

NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.

* fix(career): bound the prepare request; validate the setlist (PR #971 review)

Both CodeRabbit findings were right.

1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.

   `await fetch(...)` only rejects on a network ERROR. A server that accepts the
   connection and then never answers hangs indefinitely — and the gig would never
   start. That makes this optimisation the exact thing the PR promises it can
   never be: the reason you cannot play.

   The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
   because unpacking a setlist is real work — but a CEILING, not a wait). Past it
   we start the gig and let the first play extract lazily, as it always did. The
   Play button is restored in a `finally`, so a timeout cannot strand the poster
   on "Preparing set…" with Play disabled — which would have been the same bug
   wearing a different hat.

2. THE `songs` BODY WAS UNVALIDATED.

   A str is iterable: "abc" would have prepared three one-character "songs". And
   the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
   Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.

Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.

* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap

CodeRabbit again, and the first one is a real hole I put there.

1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
   with NO containment guard — so `../../x` walks straight out of the library, and
   my new endpoint handed it attacker-supplied filenames. Every filename now goes
   through _resolve_dlc_path first, the same check every other filename-bound
   handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
   a Windows drive path are all refused, and nothing outside the library is
   unpacked.

2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
   library — where the endpoint exits before extraction — so it passed whether or
   not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
   endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
   fail when the cap is removed.

   Same class of mistake as the notedetect gigBlock: a test that passes for the
   wrong reason. Worth saying out loud since it is twice in one day.

3. E702 — semicolon-joined statements in the new tests, split.

51 career tests; full suite green.
2026-07-15 00:36:20 +02:00
Byron GamatosandGitHub 917d81c2d2 fix(highway): a SUPERSEDED renderer init is not a FAILED one (#970)
Starting a gig dropped the player onto the fallback 2D highway with no venue.

startGig() calls setViz('venue'), which installs the 3D renderer — whose init is
async — and then immediately starts its play queue. playSong() re-initialises
that same renderer a tick later. A renderer mints a fresh readyPromise per
init() and rejects the previous one with "superseded"; 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)

The guard now also checks the PROMISE identity: a rejection from an init cycle
the renderer has already moved on from is ignored. The renderer-identity guard
stays (a rejection for a renderer since REPLACED is also not ours), and a
genuine failure of the CURRENT cycle still reverts — both init() call sites go
through _setRenderer, which re-wires the handler every time, so the new cycle is
always watched.

Reproduced and fixed against the real build:

    before:  vizSelection=default  viz-picker=default  venue=inactive  viz:reverted
    after:   vizSelection=venue    viz-picker=venue    venue=ACTIVE    (no revert)

Also widens the paused-frame throttle's opt-out. The throttle fires whenever the
CHART CLOCK is stalled — not only on a pause, but through a count-in and the
credits/author overlay too. Its opt-out only asked "is a crowd video rolling",
but the venue scene animates on a clock of its own with no pack at all (backdrop
breathe, parallax, haze drift, warmth pulse — Math.sin(t) in the draw loop), so
that motion was still being throttled. It now claims frames for both sources; a
plain 3D highway with no venue reads motion mode 'off' and keeps the #654 GPU
saving.

HONEST CAVEAT on that second part: I could not get the throttle to fire in a
reproduction. A control run on the shipped code showed 100 draws/sec while
paused, not the ~10/sec a firing throttle would give — so the change is
defensible on its own terms (a stalled clock is genuinely not a static picture)
but it does NOT have a demonstrated symptom behind it. The viz fix above does.

Tests: the superseded guard, and that the throttle opt-out covers both motion
sources. All fail against the pre-fix source. eslint 0 errors; JS 1207/1207.
2026-07-15 00:36:16 +02:00
Byron GamatosandGitHub 939c98214b feat(song-info): publish the playable stem list so stems can preload (fixes the 698ms freeze) (#972)
* feat(song-info): publish the playable stem list, so stems can preload

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 fetched, decoded, and then
handed every stem's PCM to its audio worklet — copying the WHOLE SONG — with the
player already on screen.

For a 4-minute 6-stem pack that is over half a GIGABYTE of memcpy, in one frame,
on the main thread. Measured on a real load: a 698 ms frame, right as the
song-credits card appeared, with the venue video visibly stopping. That is the
"the video pauses when the author appears" report.

GET /api/song/{f}?stems=1 now returns the same list — [{id, url, default}] plus
full_mix_url — so the plugin can start the whole load at `song:loading`, before
the highway (and the venue) is drawn, where a stalled frame costs nothing.
Nothing about the work changes; only WHEN.

Opt-in via the query param so the library's own metadata calls — the hot path —
pay nothing. Deliberately NOT stored in the metadata cache: that is a
fixed-column table, and widening it would mean a schema migration plus a stale
row for every song already scanned, to cache something that is a plain manifest
read on an already-unpacked pack.

The safety property: REST and the WS must publish the SAME list. If they
disagreed the plugin would preload a graph and then throw it away and rebuild —
strictly worse than not preloading. So both now resolve `default` through one
shared helper (stem_default_on, extracted from load_song), and a test rebuilds
the WS's payload from load_song and requires the REST helper to produce the
identical list, rather than pinning either against a snapshot.

Also pinned: the mixdown is lifted OUT of the stem list (spec 5.3 — `full` is
not a layer; listing it beside the instruments would play the whole song on top
of the stems) while staying reachable as full_mix_url, a single-`full` pack keeps
it as its only playable stem, and an unreadable pack yields an empty list rather
than failing the request. Full suite 2608 passed.

Consumed by feedBack-plugin-stems (preloadSong).

* fix(song-info): call load_song for the stem payload — do not reimplement it

CodeRabbit caught a real bug, and it would have hit most real libraries.

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. My
payload rebuilt the full-mix rule from extract_meta and returned None for those:
REST would say "no full mix" while the WS said there was one.

Worse than a wrong field: the plugin would preload a graph WITHOUT the pristine
mix and — because the stem signature still matched — never rebuild. Unity
playback would silently downgrade to the lossy stem recombination.

That is exactly the drift this PR claims to prevent, and my test had a hole: I
only covered packs that carry a `full` stem.

So stop reimplementing. The payload now calls load_song, whose LoadedSloppak
already carries the partitioned stems and the resolved full mix, and builds the
URLs exactly as ws_highway does. Drift is now impossible by construction rather
than by agreement. extract_meta is reverted to its original shape (it never
needed to change), and the shared stem_default_on helper stays as the one place
`default: off` is resolved.

Tests rewritten to compare against load_song — the WS's own function — for a
reserved-`full` pack, a LEGACY original_audio pack (the case that was broken), and
a single-`full` pack. Also documents the `?stems=1` contract in CHANGELOG.md.
Full suite green.
2026-07-15 00:36:13 +02:00
11 changed files with 413 additions and 37 deletions
+10
View File
@@ -46,6 +46,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
carry their gig log; instruments their gig count.
### 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),
+69 -5
View File
@@ -829,9 +829,60 @@ def post_song_gap_fill(filename: str, data: dict):
return {"ok": True, "written": additions, "skipped": skipped}
def _playable_stems_payload(filename: str, dlc) -> dict:
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
Why it exists: the stems plugin could only learn its stem list from the
highway's WS `ready`, which arrives once the highway is already up. So it
decoded, and then copied the whole song's PCM to its worklet, with the player
on screen — half a gigabyte of memcpy in one frame, ~700 ms, freezing the
venue video. Given the list at `song:loading` it can do all of that BEFORE the
highway appears, behind the loading overlay where a stall costs nothing.
The list MUST be the same one the WS sends a moment later. If it is not, the
plugin preloads a graph and then throws it away and rebuilds — strictly worse
than not preloading. So this does not reimplement the WS's construction, it
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
partitioned stems and the resolved full mix, and then builds the URLs exactly
as ws_highway does. Drift is impossible by construction rather than by
agreement — which matters, because `full_mix` in particular is not simply the
`full` stem: load_song falls back to the deprecated `original_audio:` key for
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
first) silently dropped the pristine full mix for most real libraries.
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
to preload: load_song raises and we return the empty list.
"""
from urllib.parse import quote
try:
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
except Exception:
return {"stems": [], "full_mix_url": None}
q_fn = quote(filename, safe="")
def _url(rel: str) -> str:
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
return {
"stems": [
{"id": s["id"], "url": _url(s["file"]), "default": s["default"]}
for s in loaded.stems
],
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
}
@router.get("/api/song/{filename:path}")
async def get_song_info(filename: str):
"""Return song metadata, from cache or by extracting it from the song source."""
async def get_song_info(filename: str, stems: int = 0):
"""Return song metadata, from cache or by extracting it from the song source.
`?stems=1` additionally returns the playable stem list with URLs, so the
stems plugin can start fetching/decoding on `song:loading` instead of waiting
for the highway's WS `ready` (see _playable_stems_payload).
"""
import asyncio
dlc = _get_dlc_dir()
if not dlc:
@@ -854,8 +905,21 @@ async def get_song_info(filename: str):
mtime, size = appstate.stat_for_cache(song_path)
cached = appstate.meta_db.get(cache_key, mtime, size)
loop = asyncio.get_event_loop()
# The stem list is NOT stored in the metadata cache: that is a fixed-column
# table, and widening it would mean a migration plus a stale row for every
# song already scanned. It is cheap to read on demand (the pack is unpacked
# by then, so this is a plain manifest read), and only the opt-in caller pays.
async def _with_stems(meta: dict) -> dict:
if not stems:
return meta
extra = await loop.run_in_executor(
None, _playable_stems_payload, filename, dlc)
return {**meta, **extra}
if cached:
return cached
return await _with_stems(cached)
# Extract in thread pool
def _extract():
@@ -863,5 +927,5 @@ async def get_song_info(filename: str):
appstate.meta_db.put(cache_key, mtime, size, meta)
return meta
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
return meta
meta = await loop.run_in_executor(None, _extract)
return await _with_stems(meta)
+19 -6
View File
@@ -80,6 +80,20 @@ def find_full_mix(stems: list[dict]) -> dict | None:
)
def stem_default_on(raw) -> bool:
"""Whether a manifest stem entry plays by default.
Absent means on. A string is honoured so a hand-written manifest can say
`default: off`. Extracted so the WS `ready` payload and the REST song-info
payload cannot drift: the stems plugin now preloads from REST and then has
to agree with what the WS says a moment later, or it would rebuild the whole
graph for nothing.
"""
if isinstance(raw, str):
return raw.lower() not in ("off", "false", "0", "no")
return bool(raw)
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
@@ -1100,12 +1114,11 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
default_val = s.get("default", True)
if isinstance(default_val, str):
default_on = default_val.lower() not in ("off", "false", "0", "no")
else:
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
stems.append({
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
})
# 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
+29 -17
View File
@@ -522,27 +522,39 @@ def _current_venue():
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
pool = [
{"filename": filename, "title": title or filename, "artist": artist or ""}
for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and filename not in exclude
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _validate_pack_dir(pack_dir: Path):
@@ -836,7 +848,7 @@ def setup(app, context):
picks.append(s)
if len(picks) < size:
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:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
+14
View File
@@ -1192,7 +1192,21 @@
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* viz optional — restore stays intact */ }
}
// Push the gig's venue pack to the crowd layer NOW.
//
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
// and pushCrowdManifest is called only from refresh() — the career
// tab's own reload. A gig navigates AWAY from the career tab to the
// player, so refresh() never runs during it, and setting the override
// above does nothing on its own. The result the testers saw: the venue
// visualization turns on (3D highway) but its crowd/stage pack never
// loads, so the song plays over the bare highway backdrop ("standard
// particles"), or over whatever venue a previous refresh() happened to
// leave applied. We just changed the override to this gig's venue, so
// re-push for it. _state is the career state the booking screen already
// fetched; guard for the rare null.
_appliedManifestVenue = null;
if (_state) pushCrowdManifest(_state);
_ppGigRun = {
songs: prop.songs,
venue_id: prop.venue_id,
+18 -4
View File
@@ -15398,15 +15398,29 @@
// highway throttled the whole room. Pausing the song dropped the
// venue, the crowd and the stage to 10 fps.
//
// Only claim continuous frames while a crowd video is actually
// rolling: with no venue pack (the common case) the paused scene IS
// static and the throttle should still save the GPU.
// 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;
}
return false;
// 'off' also covers prefers-reduced-motion and "no venue scene".
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
},
draw(bundle) {
+16
View File
@@ -986,6 +986,22 @@ function createHighway() {
// inline arrow function.
function _handleAsyncInitFailure(e) {
if (hwState._renderer !== _installedRenderer) return;
// ...and ignore a rejection from a SUPERSEDED init cycle.
//
// A renderer mints a fresh readyPromise on every init(), and
// rejects the previous one ("superseded") when a newer init
// starts. The renderer object is unchanged, so the identity
// check above does not catch it — and we would tear down a
// perfectly healthy renderer that is merely re-initialising.
//
// This is exactly what starting a gig did: setViz('venue')
// installed the 3D renderer, then the queue's playSong()
// re-initialised it a tick later; init #1's promise rejected,
// and the gig dropped to the fallback 2D highway with the
// venue gone. A superseded init is not a failed init — the
// NEW cycle owns the outcome, and its own promise is what we
// must judge.
if (_installedRenderer.readyPromise !== rp) return;
console.error('renderer async init failure:', e);
_destroyCurrentIfInited();
hwState._renderer = _defaultRenderer;
+27
View File
@@ -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).
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');
});
+44 -5
View File
@@ -114,13 +114,52 @@ test('the capability probe fails closed (absent / non-function / throwing)', ()
'only an explicit true opts out — a truthy accident must not disable the throttle');
});
test('3D highway claims continuous frames only while a crowd video is rolling', () => {
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 — throttle should still apply');
// With no venue pack (the common case) the paused scene really is static and
// the GPU saving must survive: the method has to be able to return false.
assert.match(fn, /return false;/, 'must fall through to false with no live video');
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');
});
+27
View File
@@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
res = client.post("/api/plugins/career/drill-state",
json={"byNode": {}, "goldImprov": blob})
assert res.status_code == 413
def test_gig_includes_songs_played_on_another_instrument(client, meta_db):
# feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list".
# A song played on a DIFFERENT instrument's arrangement has a stats row, so it
# was excluded from the unplayed filler — and its played bucket is that other
# instrument's, not this passport's — so it fell into a gap and could never be
# gigged. A guitar passport with a library of bass-played metalcore got a 404.
for i in range(137):
meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
assert res.status_code == 200, "a full library of the genre must never 404"
assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap"
def test_gig_reroll_changes_the_set(client, meta_db):
# feedBack#… (tester): "Passport re-roll does not change songs". A set drawn
# from the filler used to be the library's first N in table order, every time.
for i in range(40):
meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore")
sets = set()
for _ in range(5):
r = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"])))
assert len(sets) > 1, "re-roll must be able to produce a different set"
+140
View File
@@ -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}