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).
This commit is contained in:
byrongamatos
2026-07-15 00:03:31 +02:00
parent 4e0e3c5417
commit b7b28deb53
3 changed files with 231 additions and 12 deletions
+65 -5
View File
@@ -829,9 +829,56 @@ 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(song_path, filename: str) -> dict:
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
Byte-for-byte the same shape the highway's WS `ready` builds — same
partition (the mixdown lifted out), same default resolution, same URL form —
because the stems plugin now preloads from THIS and then has to agree with
what the WS says a moment later, or it rebuilds the graph for nothing.
Why it exists: the stems plugin could only learn its stem list from the WS
`ready`, which arrives once the highway is up. So it decoded, and then copied
the whole song's PCM to its worklet, with the player already 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.
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
nothing for it.
"""
from urllib.parse import quote
try:
meta = sloppak_mod.extract_meta(song_path)
except Exception:
return {"stems": [], "full_mix_url": None}
q_fn = quote(filename, safe="")
stems = [
{
"id": s["id"],
"url": f"/api/sloppak/{q_fn}/file/{quote(s['file'])}",
"default": bool(s.get("default", True)),
}
for s in (meta.get("stems") or [])
if s.get("id") and s.get("file")
]
full_file = meta.get("full_mix_file")
return {
"stems": stems,
"full_mix_url": f"/api/sloppak/{q_fn}/file/{quote(full_file)}" if full_file 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 +901,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, song_path, filename)
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 +923,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)
+35 -7
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]]: 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
@@ -1265,7 +1278,15 @@ def extract_meta(path: Path) -> dict:
isinstance(sid, str) and sid isinstance(sid, str) and sid
and isinstance(sfile, str) and sfile and isinstance(sfile, str) and sfile
): ):
valid_stems.append({"id": sid, "file": sfile}) # `file` and `default` ride along so the REST song-info payload can
# publish the same playable-stem list the WS `ready` message does —
# the stems plugin needs it BEFORE the highway connects (see
# get_song_info). Same helper as load_song, so they cannot disagree.
valid_stems.append({
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
})
# Partition exactly as load_song() does, for the same reason the library # Partition exactly as load_song() does, for the same reason the library
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3). # filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
# A separated pack that retains it would otherwise offer the user a "full" # A separated pack that retains it would otherwise offer the user a "full"
@@ -1292,4 +1313,11 @@ def extract_meta(path: Path) -> dict:
"stem_count": stem_count, "stem_count": stem_count,
# feedBack#129: per-stem filter needs the id list, not just count. # feedBack#129: per-stem filter needs the id list, not just count.
"stem_ids": stem_ids, "stem_ids": stem_ids,
# The PLAYABLE stems (id/file/default), partitioned exactly as load_song
# does — the mixdown lifted out, never a layer. get_song_info turns these
# into URLs so the stems plugin can start fetching and decoding on
# `song:loading`, instead of waiting for the highway's WS `ready`.
"stems": instrument_stems,
# The mixdown, when the pack carries one (spec §5.3). Same reason.
"full_mix_file": (_full or {}).get("file") or None,
} }
+131
View File
@@ -0,0 +1,131 @@
"""`/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"):
manifest = {
"title": "T", "artist": "A", "duration": 10.0,
"arrangements": [],
"stems": stems,
}
if full:
manifest["stems"] = stems + [full]
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))
return p
def test_extract_meta_carries_file_and_default(tmp_path):
p = _pak(tmp_path, [
{"id": "guitar", "file": "stems/guitar.ogg"}, # absent => on
{"id": "vocals", "file": "stems/vocals.ogg", "default": False},
{"id": "drums", "file": "stems/drums.ogg", "default": "off"}, # string form
])
meta = sloppak.extract_meta(p)
by_id = {s["id"]: s for s in meta["stems"]}
assert by_id["guitar"]["default"] is True, "absent default means ON"
assert by_id["vocals"]["default"] is False
assert by_id["drums"]["default"] is False, "'off' must be honoured"
assert by_id["guitar"]["file"] == "stems/guitar.ogg"
def test_the_mixdown_is_lifted_out_of_the_stem_list(tmp_path):
# `full` is the mixdown, not a layer (spec 5.3). Listing it beside the
# instruments would make the plugin play the whole song ON TOP of the stems.
p = _pak(tmp_path,
[{"id": "guitar", "file": "stems/guitar.ogg"},
{"id": "bass", "file": "stems/bass.ogg"}],
full={"id": "full", "file": "stems/full.ogg"})
meta = sloppak.extract_meta(p)
ids = [s["id"] for s in meta["stems"]]
assert ids == ["guitar", "bass"], "the mixdown must not be a layer"
assert meta["full_mix_file"] == "stems/full.ogg", "...but it must still be reachable"
def test_a_single_full_pack_keeps_full_as_its_only_stem(tmp_path):
# A pack whose ONLY stem is `full` is a single-mix pack: there is nothing to
# be pristine against, so `full` stays playable and no mixdown is surfaced.
p = _pak(tmp_path, [{"id": "full", "file": "stems/full.ogg"}])
meta = sloppak.extract_meta(p)
assert [s["id"] for s in meta["stems"]] == ["full"]
assert meta["full_mix_file"] is None
def test_default_resolution_is_shared_with_load_song(tmp_path):
# The whole point: REST and the WS must not drift. Both go through
# stem_default_on, so pin the helper's contract directly.
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_payload_matches_what_the_ws_would_build(tmp_path):
"""The safety property, pinned end to end.
Rebuild the WS's stems_payload from load_song exactly as ws_highway does,
and require the REST helper to produce the identical list.
"""
from urllib.parse import quote
from routers.song import _playable_stems_payload
p = _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")
cache = tmp_path / "cache"
cache.mkdir()
loaded = sloppak.load_song(p.name, tmp_path, cache)
q_fn = quote(p.name, safe="")
ws_stems = [
{"id": s["id"], "url": f"/api/sloppak/{q_fn}/file/{quote(s['file'])}",
"default": s["default"]}
for s in loaded.stems
]
ws_full = f"/api/sloppak/{q_fn}/file/{quote(loaded.full_mix)}" if loaded.full_mix else None
rest = _playable_stems_payload(p, p.name)
assert rest["stems"] == ws_stems, (
"REST and the WS must publish the SAME stem list — a mismatch means the "
"plugin preloads a graph and then rebuilds it, which is worse than not "
"preloading at all"
)
assert rest["full_mix_url"] == ws_full
def test_a_broken_pack_yields_an_empty_list_not_an_error(tmp_path):
# Preloading is an optimisation. A pack we cannot read must fall back to the
# normal WS-driven path, never break the song-info request.
from routers.song import _playable_stems_payload
bad = tmp_path / "bad.feedpak"
bad.write_bytes(b"not a zip")
assert _playable_stems_payload(bad, "bad.feedpak") == {"stems": [], "full_mix_url": None}