mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 02:44:30 +00:00
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.
This commit is contained in:
@@ -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.
|
carry their gig log; instruments their gig count.
|
||||||
|
|
||||||
### Changed
|
### 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
|
- **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>`
|
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),
|
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
|
||||||
|
|||||||
+30
-26
@@ -829,45 +829,49 @@ 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:
|
def _playable_stems_payload(filename: str, dlc) -> dict:
|
||||||
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
|
"""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
|
Why it exists: the stems plugin could only learn its stem list from the
|
||||||
partition (the mixdown lifted out), same default resolution, same URL form —
|
highway's WS `ready`, which arrives once the highway is already up. So it
|
||||||
because the stems plugin now preloads from THIS and then has to agree with
|
decoded, and then copied the whole song's PCM to its worklet, with the player
|
||||||
what the WS says a moment later, or it rebuilds the graph for nothing.
|
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.
|
||||||
|
|
||||||
Why it exists: the stems plugin could only learn its stem list from the WS
|
The list MUST be the same one the WS sends a moment later. If it is not, the
|
||||||
`ready`, which arrives once the highway is up. So it decoded, and then copied
|
plugin preloads a graph and then throws it away and rebuilds — strictly worse
|
||||||
the whole song's PCM to its worklet, with the player already on screen — half
|
than not preloading. So this does not reimplement the WS's construction, it
|
||||||
a gigabyte of memcpy in one frame, ~700 ms, freezing the venue video (#…).
|
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
|
||||||
Given the list at `song:loading` it can do all of that BEFORE the highway
|
partitioned stems and the resolved full mix, and then builds the URLs exactly
|
||||||
appears, behind the loading overlay where a stall costs nothing.
|
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
|
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
|
||||||
nothing for it.
|
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
|
from urllib.parse import quote
|
||||||
|
|
||||||
try:
|
try:
|
||||||
meta = sloppak_mod.extract_meta(song_path)
|
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"stems": [], "full_mix_url": None}
|
return {"stems": [], "full_mix_url": None}
|
||||||
|
|
||||||
q_fn = quote(filename, safe="")
|
q_fn = quote(filename, safe="")
|
||||||
stems = [
|
|
||||||
{
|
def _url(rel: str) -> str:
|
||||||
"id": s["id"],
|
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
|
||||||
"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 {
|
return {
|
||||||
"stems": stems,
|
"stems": [
|
||||||
"full_mix_url": f"/api/sloppak/{q_fn}/file/{quote(full_file)}" if full_file else None,
|
{"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -911,7 +915,7 @@ async def get_song_info(filename: str, stems: int = 0):
|
|||||||
if not stems:
|
if not stems:
|
||||||
return meta
|
return meta
|
||||||
extra = await loop.run_in_executor(
|
extra = await loop.run_in_executor(
|
||||||
None, _playable_stems_payload, song_path, filename)
|
None, _playable_stems_payload, filename, dlc)
|
||||||
return {**meta, **extra}
|
return {**meta, **extra}
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
|
|||||||
+1
-16
@@ -1278,15 +1278,7 @@ 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
|
||||||
):
|
):
|
||||||
# `file` and `default` ride along so the REST song-info payload can
|
valid_stems.append({"id": sid, "file": sfile})
|
||||||
# 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"
|
||||||
@@ -1313,11 +1305,4 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,60 +21,52 @@ import yaml
|
|||||||
import sloppak
|
import sloppak
|
||||||
|
|
||||||
|
|
||||||
def _pak(tmp_path, stems, full=None, name="song.feedpak"):
|
def _pak(tmp_path, stems, full=None, name="song.feedpak", original_audio=None):
|
||||||
manifest = {
|
manifest = {
|
||||||
"title": "T", "artist": "A", "duration": 10.0,
|
"title": "T", "artist": "A", "duration": 10.0,
|
||||||
"arrangements": [],
|
"arrangements": [],
|
||||||
"stems": stems,
|
"stems": stems + ([full] if full else []),
|
||||||
}
|
}
|
||||||
if full:
|
if original_audio:
|
||||||
manifest["stems"] = stems + [full]
|
# The deprecated pre-1.15.0 shape: the mixdown lives outside `stems`.
|
||||||
|
manifest["original_audio"] = original_audio
|
||||||
p = tmp_path / name
|
p = tmp_path / name
|
||||||
with zipfile.ZipFile(p, "w") as z:
|
with zipfile.ZipFile(p, "w") as z:
|
||||||
# Real packs carry manifest.yaml — a JSON manifest is not read at all.
|
# Real packs carry manifest.yaml — a JSON manifest is not read at all.
|
||||||
z.writestr("manifest.yaml", yaml.safe_dump(manifest))
|
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
|
return p
|
||||||
|
|
||||||
|
|
||||||
def test_extract_meta_carries_file_and_default(tmp_path):
|
def _payload(tmp_path, pak):
|
||||||
p = _pak(tmp_path, [
|
from routers.song import _playable_stems_payload
|
||||||
{"id": "guitar", "file": "stems/guitar.ogg"}, # absent => on
|
import appstate
|
||||||
{"id": "vocals", "file": "stems/vocals.ogg", "default": False},
|
cache = tmp_path / "cache"
|
||||||
{"id": "drums", "file": "stems/drums.ogg", "default": "off"}, # string form
|
cache.mkdir(exist_ok=True)
|
||||||
])
|
appstate.sloppak_cache_dir = cache
|
||||||
meta = sloppak.extract_meta(p)
|
return _playable_stems_payload(pak.name, tmp_path)
|
||||||
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):
|
def _ws_payload(tmp_path, pak):
|
||||||
# `full` is the mixdown, not a layer (spec 5.3). Listing it beside the
|
"""Rebuild the WS `ready` stems payload exactly as ws_highway.py does."""
|
||||||
# instruments would make the plugin play the whole song ON TOP of the stems.
|
from urllib.parse import quote
|
||||||
p = _pak(tmp_path,
|
cache = tmp_path / "cache"
|
||||||
[{"id": "guitar", "file": "stems/guitar.ogg"},
|
cache.mkdir(exist_ok=True)
|
||||||
{"id": "bass", "file": "stems/bass.ogg"}],
|
loaded = sloppak.load_song(pak.name, tmp_path, cache)
|
||||||
full={"id": "full", "file": "stems/full.ogg"})
|
q = quote(pak.name, safe="")
|
||||||
meta = sloppak.extract_meta(p)
|
return {
|
||||||
ids = [s["id"] for s in meta["stems"]]
|
"stems": [
|
||||||
assert ids == ["guitar", "bass"], "the mixdown must not be a layer"
|
{"id": s["id"], "url": f"/api/sloppak/{q}/file/{quote(s['file'])}",
|
||||||
assert meta["full_mix_file"] == "stems/full.ogg", "...but it must still be reachable"
|
"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_a_single_full_pack_keeps_full_as_its_only_stem(tmp_path):
|
def test_default_resolution_is_shared_with_load_song():
|
||||||
# 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(True) is True
|
||||||
assert sloppak.stem_default_on(False) is False
|
assert sloppak.stem_default_on(False) is False
|
||||||
assert sloppak.stem_default_on("off") is False
|
assert sloppak.stem_default_on("off") is False
|
||||||
@@ -85,47 +77,64 @@ def test_default_resolution_is_shared_with_load_song(tmp_path):
|
|||||||
assert sloppak.stem_default_on(1) is True
|
assert sloppak.stem_default_on(1) is True
|
||||||
|
|
||||||
|
|
||||||
def test_rest_payload_matches_what_the_ws_would_build(tmp_path):
|
def test_rest_matches_the_ws_for_a_reserved_full_stem(tmp_path):
|
||||||
"""The safety property, pinned end to end.
|
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
|
||||||
|
|
||||||
Rebuild the WS's stems_payload from load_song exactly as ws_highway does,
|
|
||||||
and require the REST helper to produce the identical list.
|
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.
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
pak = _pak(tmp_path, [
|
||||||
from routers.song import _playable_stems_payload
|
{"id": "guitar", "file": "stems/guitar.ogg"},
|
||||||
|
{"id": "bass", "file": "stems/bass.ogg"},
|
||||||
|
], name="Legacy Pack.feedpak", original_audio="original/full.ogg")
|
||||||
|
|
||||||
p = _pak(tmp_path,
|
rest = _payload(tmp_path, pak)
|
||||||
[{"id": "guitar", "file": "stems/guitar.ogg"},
|
assert rest == _ws_payload(tmp_path, pak)
|
||||||
{"id": "vocals", "file": "stems/vocals.ogg", "default": "off"}],
|
assert rest["full_mix_url"] is not None, (
|
||||||
full={"id": "full", "file": "stems/full.ogg"},
|
"a pre-1.15.0 pack's full mix must survive — dropping it downgrades unity "
|
||||||
name="Iron Maiden - Phantom.feedpak")
|
"playback to the lossy stem recombination, silently"
|
||||||
|
|
||||||
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
|
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):
|
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
|
# Preloading is an optimisation: an unreadable pack must fall back to the
|
||||||
# normal WS-driven path, never break the song-info request.
|
# normal WS-driven path, never break the song-info request.
|
||||||
from routers.song import _playable_stems_payload
|
from routers.song import _playable_stems_payload
|
||||||
bad = tmp_path / "bad.feedpak"
|
import appstate
|
||||||
bad.write_bytes(b"not a zip")
|
cache = tmp_path / "cache"
|
||||||
assert _playable_stems_payload(bad, "bad.feedpak") == {"stems": [], "full_mix_url": None}
|
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