Compare commits

..
Author SHA1 Message Date
byrongamatos 4a1b055afd fix(highway): the paused-frame throttle was throttling the whole venue
Pausing the song dropped the venue, the crowd and the stage to ~10 fps —
"everything around the highway drops fps by a lot".

draw() caps paused frames to one per _PAUSED_FRAME_INTERVAL_MS (100ms), on an
assumption stated plainly in highway-constants.js: a heavy WebGL renderer "does
a full render every frame even while paused. That is pure waste." That was true
when a paused chart was a still picture.

The venue broke the assumption. Its video backdrop keeps playing and its crowd
reacts on a clock of their own, and BOTH are drawn into the same canvas as the
notes — so a throttle aimed at static notes throttled the entire room. The
scene only got a texture upload 10 times a second while the transport sat
paused.

Renderers can now declare that their picture is not static while the chart
clock is stopped: an optional needsContinuousFrames(). The throttle is skipped
only when it returns exactly true, and the probe fails closed — a renderer that
doesn't implement it, or one that throws, keeps the throttle unchanged. So the
GPU saving that motivated #654 survives everywhere it was actually valid.

highway_3d implements it and claims continuous frames ONLY while a crowd video
is genuinely rolling (bound, unpaused, not ended, readyState >= 2). With no
venue pack — the common case — the paused scene really is static, so it keeps
the throttle and the GPU still idles.

Tests extend tests/js/highway_pause_throttle.test.js, which guards this code
path source-level (the draw loop owns the rAF + WebGL lifecycle and is
deliberately not reproduced in a vm — see the file header). The new guards pin
that the capability GATES the early return rather than merely being called near
it, that the probe fails closed on absent/non-function/throwing/truthy-but-not-
true, and that the 3D renderer keys off the real video elements and can still
return false. All 3 fail against the pre-fix source.

eslint 0 errors; JS 1202/1202; pytest 2597 passed.
2026-07-14 22:03:50 +02:00
byrongamatos d73e900e35 fix(venue): don't replay the flyover on an arrangement switch; keep the venue off other screens
Two bugs from a live career session.

1. CHANGING ARRANGEMENT REPLAYED THE ARRIVAL FLYOVER.

   changeArrangement() reloads the song through the normal load path, so
   highway.js re-emits `song:loaded` — same filename, new arrangement. The venue
   could not tell that from a fresh arrival, so it reset the machine and flew the
   camera in from the back of the room again, mid-set, every time the player
   switched lead -> rhythm. The player is already on stage.

   onSongLoaded now compares the filename. A repeat of the song already on stage
   keeps the video pipeline running and only re-syncs the mood: the performance
   restarts, so the loop follows the reset machine with a quiet crossfade, never
   the intro. A genuinely different song still gets the full teardown + flyover.

2. THE VENUE SHOWED UP ON THE VIRTUOSO HIGHWAY.

   The venue was gated purely on `isVenueViz()` — the selected visualization,
   which is a GLOBAL preference and says nothing about what is on screen.
   Virtuoso borrows the same highway_3d renderer for its practice charts, so with
   Venue selected it inherited the backdrop: the crowd and the stage behind a
   chromatic exercise.

   Selecting Venue is a preference for the PLAYER; it is not a licence to paint
   the venue over whatever else happens to be using the renderer. The venue is now
   gated on viz AND screen (`shouldBeActive`), and follows `screen:changed` — it
   tears down on leaving the player and rebuilds on return. Nothing else changes:
   stop() already unbinds the videos from the renderer, so deactivating is enough
   to clear the backdrop.

Tests: both decisions exposed as pure predicates and pinned — arrangement switch
vs new song (including the first load, and a malformed payload that must not
suppress the flyover forever), and the venue's screen scope. The existing syncViz
test encoded the OLD contract (activate regardless of screen), so it now states
the new one and additionally asserts the venue does NOT activate on virtuoso.

Includes a guard test: with Venue selected AND on the player, the venue IS
active — without it, every "not active" assertion could pass vacuously.

All 8 new/updated assertions fail against the pre-fix source. eslint clean;
JS 1199/1199; pytest 2597 passed.
2026-07-14 21:57:27 +02:00
4 changed files with 11 additions and 238 deletions
-10
View File
@@ -46,16 +46,6 @@ 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),
+5 -69
View File
@@ -829,60 +829,9 @@ 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, 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).
"""
async def get_song_info(filename: str):
"""Return song metadata, from cache or by extracting it from the song source."""
import asyncio
dlc = _get_dlc_dir()
if not dlc:
@@ -905,21 +854,8 @@ async def get_song_info(filename: str, stems: int = 0):
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 await _with_stems(cached)
return cached
# Extract in thread pool
def _extract():
@@ -927,5 +863,5 @@ async def get_song_info(filename: str, stems: int = 0):
appstate.meta_db.put(cache_key, mtime, size, meta)
return meta
meta = await loop.run_in_executor(None, _extract)
return await _with_stems(meta)
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
return meta
+6 -19
View File
@@ -80,20 +80,6 @@ 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.
@@ -1114,11 +1100,12 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
stems.append({
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
})
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})
# 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
-140
View File
@@ -1,140 +0,0 @@
"""`/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}