mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 07:04:31 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9180a8182 | ||
|
|
7fa60e5ef6 | ||
|
|
1702afa379 | ||
|
|
917d81c2d2 | ||
|
|
939c98214b |
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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