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}
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}")
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 +901,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, song_path, filename)
return {**meta, **extra}
if cached:
return cached
return await _with_stems(cached)
# Extract in thread pool
def _extract():
@@ -863,5 +923,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)