mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 18:38:32 +00:00
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:
+65
-5
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user