mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 08:14:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffe018b39a | ||
|
|
1702afa379 | ||
|
|
917d81c2d2 | ||
|
|
939c98214b | ||
|
|
4e0e3c5417 | ||
|
|
8ef97708ef |
@@ -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.
|
||||
|
||||
### 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),
|
||||
|
||||
+69
-5
@@ -829,9 +829,60 @@ 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):
|
||||
"""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 +905,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, filename, dlc)
|
||||
return {**meta, **extra}
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
return await _with_stems(cached)
|
||||
|
||||
# Extract in thread pool
|
||||
def _extract():
|
||||
@@ -863,5 +927,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)
|
||||
|
||||
+19
-6
@@ -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]]:
|
||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||
|
||||
@@ -1100,12 +1114,11 @@ def load_song(
|
||||
sfile = str(s.get("file", ""))
|
||||
if not sid or not sfile:
|
||||
continue
|
||||
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})
|
||||
stems.append({
|
||||
"id": sid,
|
||||
"file": sfile,
|
||||
"default": stem_default_on(s.get("default", True)),
|
||||
})
|
||||
|
||||
# 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
|
||||
|
||||
@@ -46,6 +46,8 @@ from pathlib import Path
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
import sloppak
|
||||
from dlc_paths import _resolve_dlc_path
|
||||
from progression import instrument_for_arrangement
|
||||
|
||||
PLUGIN_ID = "career"
|
||||
@@ -53,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||
DOWNLOAD_CHUNK = 1024 * 256
|
||||
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
|
||||
# arbitrary caller can ask for.
|
||||
MAX_GIG_SONGS = 32
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state = {
|
||||
@@ -734,6 +739,62 @@ def setup(app, context):
|
||||
"snapshot": snapshot})
|
||||
return {"ok": True}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
|
||||
def prepare_gig(body: dict = Body(...)):
|
||||
"""Unpack every song of the set BEFORE the gig starts.
|
||||
|
||||
A feedpak is a zip: the first play of one pays for its extraction into
|
||||
sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
|
||||
finished a number and then sat waiting for the next one to unpack, mid-
|
||||
gig. A set is a known list up front, so extract it all while the player
|
||||
is still looking at the poster.
|
||||
|
||||
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
|
||||
already-unpacked dir without rewriting it. Best-effort per song — one
|
||||
bad feedpak must not block the set from starting (the play itself will
|
||||
surface the error, exactly as it does outside a gig).
|
||||
"""
|
||||
raw = (body or {}).get("songs")
|
||||
# A str is iterable: without the list check, "abc" would prepare three
|
||||
# one-character "songs". Cap the count too — this endpoint unpacks zips,
|
||||
# so an oversized list is real work, and a setlist is a handful of songs.
|
||||
if not isinstance(raw, list):
|
||||
return {"ok": True, "prepared": 0, "failed": []}
|
||||
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
|
||||
if not files:
|
||||
return {"ok": True, "prepared": 0, "failed": []}
|
||||
|
||||
# .get, not []: a host that doesn't hand us the resolvers (or has no
|
||||
# library configured) must degrade to "extract lazily, as before" — this
|
||||
# is an optimisation, and it is never allowed to be the thing that stops
|
||||
# a gig from starting.
|
||||
get_dlc = context.get("get_dlc_dir")
|
||||
get_cache = context.get("get_sloppak_cache_dir")
|
||||
dlc_root = get_dlc() if callable(get_dlc) else None
|
||||
cache_root = get_cache() if callable(get_cache) else None
|
||||
if dlc_root is None or cache_root is None:
|
||||
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
|
||||
|
||||
root = Path(dlc_root)
|
||||
prepared, failed = 0, []
|
||||
for fn in files:
|
||||
# CONTAINMENT FIRST. resolve_source_dir() does a bare
|
||||
# `dlc_root / filename` with no guard, so a crafted `../..` would
|
||||
# walk straight out of the library. Every other filename-bound
|
||||
# handler validates through _resolve_dlc_path; so does this one.
|
||||
safe = _resolve_dlc_path(root, fn)
|
||||
if safe is None:
|
||||
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
|
||||
failed.append(fn)
|
||||
continue
|
||||
try:
|
||||
sloppak.resolve_source_dir(fn, root, Path(cache_root))
|
||||
prepared += 1
|
||||
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
|
||||
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
|
||||
failed.append(fn)
|
||||
return {"ok": True, "prepared": prepared, "failed": failed}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||
def propose_gig(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
'use strict';
|
||||
|
||||
const API = '/api/plugins/career';
|
||||
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
|
||||
// so this is generous — but it is a CEILING, not a wait. Past it we start the
|
||||
// gig and let the first play extract lazily, as it always did.
|
||||
const PREPARE_TIMEOUT_MS = 60000;
|
||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||
const NO_VENUE = '__none__';
|
||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||
@@ -1123,10 +1127,52 @@
|
||||
sfx('page');
|
||||
}
|
||||
|
||||
function startGig() {
|
||||
// Unpack the whole set before the first note.
|
||||
//
|
||||
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
||||
// a set that cost landed BETWEEN songs: the player finished a number and
|
||||
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
|
||||
// known up front, so warm it all while the poster is still on screen.
|
||||
//
|
||||
// Best-effort by design: a library that won't pre-extract must not stop the
|
||||
// gig from starting — the play itself surfaces the error the same way it
|
||||
// does outside a gig. Slow is better than blocked.
|
||||
async function prepareGigSongs(prop, btn) {
|
||||
const label = btn && btn.textContent;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
|
||||
// A bare `await fetch(...)` only rejects on a network ERROR — a server
|
||||
// that accepts the connection and then never answers hangs forever, and
|
||||
// the gig would never start. That would make this optimisation the very
|
||||
// thing it promises never to be: the reason you cannot play. Give up
|
||||
// waiting and let the first play extract lazily, exactly as before.
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
|
||||
try {
|
||||
await fetch(`${API}/gigs/prepare`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (_) {
|
||||
// abort, offline, non-2xx — all the same: start the gig anyway.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
|
||||
}
|
||||
}
|
||||
|
||||
async function startGig(btn) {
|
||||
const prop = _ppGigProposal;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
|
||||
|
||||
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
|
||||
// so a failure here leaves nothing half-applied to unwind.
|
||||
await prepareGigSongs(prop, btn);
|
||||
// The poster's Play could have been cancelled while we were unpacking.
|
||||
if (_ppGigProposal !== prop) return;
|
||||
|
||||
// The gig BORROWS the stage: stash whatever venue/viz the user had so
|
||||
// the set ending gives it back (unlike "Play here", which is an
|
||||
// explicit persistent choice on the venue card).
|
||||
@@ -1424,7 +1470,7 @@
|
||||
}
|
||||
const gigBtn = e.target.closest('[data-pp-gig]');
|
||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
|
||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
||||
if (e.target.closest('[data-pp-gig-reroll]')) {
|
||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||
return;
|
||||
|
||||
@@ -15388,6 +15388,41 @@
|
||||
});
|
||||
},
|
||||
|
||||
// The host throttles paused frames to ~10 fps, on the assumption
|
||||
// that a paused chart is a static picture and re-rendering it is
|
||||
// pure waste (highway-constants._PAUSED_FRAME_INTERVAL_MS).
|
||||
//
|
||||
// That stopped being true when the venue landed. The venue backdrop
|
||||
// is a PLAYING VIDEO and the crowd reacts on its own clock, and they
|
||||
// are drawn into this same canvas as the highway — so throttling the
|
||||
// highway throttled the whole room. Pausing the song dropped the
|
||||
// venue, the crowd and the stage to 10 fps.
|
||||
//
|
||||
// Two independent sources of motion, and BOTH must keep their frames:
|
||||
//
|
||||
// • a crowd video rolling on its own clock (career venue pack), and
|
||||
// • the venue scene's own fake-depth motion — the backdrop breathes,
|
||||
// the haze drifts, warmth pulses, the shimmer moves. That is
|
||||
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
|
||||
// so it only moves while we are actually given frames, and it runs
|
||||
// with NO pack at all.
|
||||
//
|
||||
// The throttle fires whenever the CHART CLOCK is stalled — which is
|
||||
// not just a pause. A count-in and the credits/author overlay stall it
|
||||
// exactly the same way, so the venue was stuttering there too.
|
||||
//
|
||||
// With no venue at all (plain 3D highway) the paused scene really is a
|
||||
// still picture: motion mode reads 'off', we claim nothing, and the
|
||||
// throttle still saves the GPU as #654 intended.
|
||||
needsContinuousFrames() {
|
||||
if (!_isReady || _ctxLost) return false;
|
||||
for (const v of _venueCrowdVideos) {
|
||||
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
|
||||
}
|
||||
// 'off' also covers prefers-reduced-motion and "no venue scene".
|
||||
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
|
||||
},
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
|
||||
+18
-1
@@ -1334,12 +1334,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
||||
// leaving the player still leaves — and abandons the queue.
|
||||
window.feedBack.playQueue = (function () {
|
||||
let list = [], idx = -1, source = '', arrangements = null;
|
||||
// Set true by _play() right before it drives playSong, consumed once by
|
||||
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
|
||||
// signal is options.fromQueue, but a chain of plugin playSong wrappers
|
||||
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the options object — so the flag
|
||||
// never arrived and the queue cleared itself the instant its first song
|
||||
// started (a gig/album/playlist never advanced). This flag rides beside the
|
||||
// wrapper chain, not through it.
|
||||
let _internalPlay = false;
|
||||
const active = () => idx >= 0 && idx < list.length;
|
||||
const hasNext = () => active() && idx < list.length - 1;
|
||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||
function _play(i) {
|
||||
const fn = list[i];
|
||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
||||
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
|
||||
// that survives wrapper chains dropping the options arg. Both set; either
|
||||
// suffices. playSong runs its clear-guard synchronously at entry, and the
|
||||
// wrapper chain reaches it synchronously, so the flag is still set then.
|
||||
_internalPlay = true;
|
||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||
}
|
||||
function start(files, opts) {
|
||||
@@ -1371,6 +1384,10 @@ window.feedBack.playQueue = (function () {
|
||||
}
|
||||
return {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
// One-shot: true iff _play just kicked off this playSong. Consumed on
|
||||
// read so a later MANUAL play still clears the queue. playSong calls this
|
||||
// instead of trusting options.fromQueue to survive the wrapper chain.
|
||||
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
|
||||
+36
-1
@@ -986,6 +986,22 @@ function createHighway() {
|
||||
// inline arrow function.
|
||||
function _handleAsyncInitFailure(e) {
|
||||
if (hwState._renderer !== _installedRenderer) return;
|
||||
// ...and ignore a rejection from a SUPERSEDED init cycle.
|
||||
//
|
||||
// A renderer mints a fresh readyPromise on every init(), and
|
||||
// rejects the previous one ("superseded") when a newer init
|
||||
// starts. The renderer object is unchanged, so the identity
|
||||
// check above does not catch it — and we would tear down a
|
||||
// perfectly healthy renderer that is merely re-initialising.
|
||||
//
|
||||
// This is exactly what starting a gig did: setViz('venue')
|
||||
// installed the 3D renderer, then the queue's playSong()
|
||||
// re-initialised it a tick later; init #1's promise rejected,
|
||||
// and the gig dropped to the fallback 2D highway with the
|
||||
// venue gone. A superseded init is not a failed init — the
|
||||
// NEW cycle owns the outcome, and its own promise is what we
|
||||
// must judge.
|
||||
if (_installedRenderer.readyPromise !== rp) return;
|
||||
console.error('renderer async init failure:', e);
|
||||
_destroyCurrentIfInited();
|
||||
hwState._renderer = _defaultRenderer;
|
||||
@@ -1159,6 +1175,17 @@ function createHighway() {
|
||||
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
|
||||
}
|
||||
|
||||
// Optional renderer capability: "my picture keeps moving even when the chart
|
||||
// clock is stopped". Anything a renderer animates on its own clock (the 3D
|
||||
// highway's venue video + crowd) has to opt out of the paused-frame throttle
|
||||
// or it renders at 10 fps while the song is paused. Absent / throwing =
|
||||
// false, so every existing renderer keeps the throttle unchanged.
|
||||
function _rendererNeedsContinuousFrames() {
|
||||
const r = hwState._renderer;
|
||||
if (!r || typeof r.needsContinuousFrames !== 'function') return false;
|
||||
try { return r.needsContinuousFrames() === true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function draw() {
|
||||
hwState.animFrame = requestAnimationFrame(draw);
|
||||
if (!hwState.canvas || !hwState._renderer) return;
|
||||
@@ -1223,7 +1250,15 @@ function createHighway() {
|
||||
const _nowP = performance.now();
|
||||
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
||||
_paused = true;
|
||||
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
// ...unless the renderer says its picture is NOT static while
|
||||
// paused. The throttle assumes a paused chart is a still frame,
|
||||
// but a renderer can own content on a clock of its own — the 3D
|
||||
// highway draws the venue's video backdrop and its reactive crowd
|
||||
// into this same canvas, so throttling the highway throttled the
|
||||
// whole room to 10 fps whenever the song was paused. Optional
|
||||
// method: renderers that don't implement it keep the throttle.
|
||||
if (!_rendererNeedsContinuousFrames()
|
||||
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
hwState._lastPausedDrawAt = _nowP;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -638,9 +638,18 @@ export let artAbortController = null;
|
||||
export async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
// can't hijack the next song's end. The queue signals a play it is DRIVING
|
||||
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
|
||||
// band). The out-of-band one exists because plugin playSong wrappers forward
|
||||
// only (filename, arrangement) and drop the options object — with just the
|
||||
// in-band flag, the queue cleared itself the instant its first song played
|
||||
// and a gig never advanced. Consume the flag whether or not we go on to clear,
|
||||
// so it can't leak into a later manual play.
|
||||
const _pq = window.feedBack && window.feedBack.playQueue;
|
||||
const _queueDriven = (options && options.fromQueue)
|
||||
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
|
||||
if (!_queueDriven && _pq) {
|
||||
_pq.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
|
||||
@@ -133,6 +133,11 @@
|
||||
let _lastStingerAt = -Infinity;
|
||||
let _prevStreak = 0;
|
||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
||||
// Filename of the song song:loaded last reported. An arrangement switch
|
||||
// re-emits song:loaded for the SAME file (changeArrangement reloads through
|
||||
// the normal load path), and that must not be mistaken for arriving at the
|
||||
// venue with a new song — see onSongLoaded.
|
||||
let _lastSongFile = '';
|
||||
let _bound = false;
|
||||
|
||||
function now() { return Date.now(); }
|
||||
@@ -478,10 +483,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onSongLoaded() {
|
||||
// song:loaded for the SAME file is an arrangement switch, not an arrival at
|
||||
// the venue. changeArrangement() reloads through the normal load path, so
|
||||
// the event is indistinguishable from a fresh load except by filename.
|
||||
function isArrangementSwitch(prevFile, nextFile) {
|
||||
return !!nextFile && nextFile === prevFile;
|
||||
}
|
||||
|
||||
function onSongLoaded(song) {
|
||||
const file = String((song && song.filename) || '');
|
||||
const sameSong = isArrangementSwitch(_lastSongFile, file);
|
||||
_lastSongFile = file;
|
||||
|
||||
machine.reset();
|
||||
_prevStreak = 0;
|
||||
_lastAccuracyPct = null;
|
||||
|
||||
// Switching arrangement is NOT arriving at the venue.
|
||||
//
|
||||
// changeArrangement() reloads the song through the same path as a fresh
|
||||
// load, so highway.js emits song:loaded again — same filename, new
|
||||
// arrangement. Treated as a new song, that replayed the arrival flyover:
|
||||
// the camera flew in from the back of the room again mid-set, every time
|
||||
// the player switched from lead to rhythm. The player is already on
|
||||
// stage; the room should just carry on.
|
||||
//
|
||||
// So keep the video pipeline running and only re-sync the mood: the
|
||||
// performance restarts, so the loop must follow the reset machine (a
|
||||
// quiet crossfade), never the intro.
|
||||
if (sameSong) {
|
||||
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// A genuinely different song — full teardown.
|
||||
// Abort any stinger/pending state from the previous song: its ended
|
||||
// handler must not fade back into the old song's layers.
|
||||
cancelFade();
|
||||
@@ -651,6 +686,7 @@
|
||||
bindRuntime,
|
||||
getState,
|
||||
celebrate,
|
||||
isArrangementSwitch,
|
||||
};
|
||||
|
||||
if (root) root.v3VenueCrowd = api;
|
||||
|
||||
@@ -18,6 +18,30 @@
|
||||
let _lastMood = 'idle';
|
||||
let _bound = false;
|
||||
|
||||
// The venue belongs to the SONG player and nowhere else.
|
||||
//
|
||||
// isVenueViz() only answers "is Venue the selected visualization" — a global
|
||||
// preference. It says nothing about what is on screen. Other surfaces borrow
|
||||
// the same highway_3d renderer (Virtuoso runs its practice charts on it), so
|
||||
// with Venue selected they inherited the venue backdrop: the crowd and the
|
||||
// stage showed up behind a chromatic exercise. The viz picker is a
|
||||
// preference for the player; it is not a licence to paint the venue over
|
||||
// whatever else happens to be using the renderer.
|
||||
//
|
||||
// So gate on both: Venue selected AND the player screen is the one showing.
|
||||
function isPlayerScreen() {
|
||||
try {
|
||||
const active = document.querySelector('.screen.active');
|
||||
return !!active && active.id === 'player';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldBeActive() {
|
||||
return isVenueViz() && isPlayerScreen();
|
||||
}
|
||||
|
||||
function isVenueViz() {
|
||||
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
||||
const sel = root.v3VenueViz.getSelectedVizId
|
||||
@@ -146,7 +170,8 @@
|
||||
|
||||
function syncViz(vizId) {
|
||||
const id = String(vizId || '');
|
||||
if (id === 'venue') {
|
||||
// Venue selected is necessary but not sufficient — see shouldBeActive.
|
||||
if (id === 'venue' && isPlayerScreen()) {
|
||||
activate();
|
||||
} else {
|
||||
deactivate();
|
||||
@@ -192,12 +217,19 @@
|
||||
if (_active) syncInstrumentPov();
|
||||
});
|
||||
sm.on('viz:renderer:ready', () => {
|
||||
if (isVenueViz()) activate();
|
||||
if (shouldBeActive()) activate();
|
||||
else deactivate();
|
||||
});
|
||||
sm.on('viz:reverted', () => deactivate());
|
||||
// Leaving the player tears the venue down; coming back rebuilds it.
|
||||
// Without this the backdrop followed the renderer onto every other
|
||||
// surface that borrows it (Virtuoso's practice highway).
|
||||
sm.on('screen:changed', () => {
|
||||
if (shouldBeActive()) activate();
|
||||
else deactivate();
|
||||
});
|
||||
}
|
||||
if (isVenueViz()) activate();
|
||||
if (shouldBeActive()) activate();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
@@ -234,6 +266,8 @@
|
||||
activate,
|
||||
deactivate,
|
||||
syncViz,
|
||||
isPlayerScreen,
|
||||
shouldBeActive,
|
||||
onAssetsLoaded,
|
||||
onAssetsFailed,
|
||||
onPerformanceState,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// A gig is a SET, not a run of unrelated songs.
|
||||
//
|
||||
// Reported from a live gig: the player finished the first song and had to sit
|
||||
// through the per-song results popup before the next one would start, and then
|
||||
// wait again while that song was extracted from its feedpak zip.
|
||||
//
|
||||
// This file covers the CORE half — career pre-extracts the whole setlist before
|
||||
// the first note. The other half (note_detect must not show its per-song summary
|
||||
// inside a gig) lives in the note_detect plugin repo, which is not part of this
|
||||
// checkout: plugins/*/ is gitignored here and note_detect ships from
|
||||
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
|
||||
// box (where the plugin happens to be bundled) and fail in CI, which is worse
|
||||
// than no test.
|
||||
//
|
||||
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
|
||||
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
|
||||
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
assert.ok(start !== -1, `signature '${signature}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
test('startGig extracts the whole setlist before starting the queue', () => {
|
||||
const fn = extractBlock(CAREER, 'async function startGig(');
|
||||
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
||||
const startIdx = fn.search(/q\.start\s*\(/);
|
||||
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
|
||||
assert.ok(startIdx !== -1, 'q.start not found');
|
||||
assert.ok(prepIdx < startIdx,
|
||||
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
|
||||
'waits between songs, which is the bug');
|
||||
});
|
||||
|
||||
test('the stage is only borrowed once the set is ready', () => {
|
||||
const fn = extractBlock(CAREER, 'async function startGig(');
|
||||
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
||||
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
|
||||
assert.ok(prepIdx < stageIdx,
|
||||
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
|
||||
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
|
||||
'a proposal dismissed while unpacking must not then start a gig');
|
||||
});
|
||||
|
||||
test('pre-extraction never blocks the gig from starting', () => {
|
||||
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
||||
assert.match(fn, /catch\s*\(/,
|
||||
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
|
||||
});
|
||||
|
||||
test('the prepare route degrades instead of failing', () => {
|
||||
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
|
||||
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
|
||||
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
|
||||
'is an optimisation and can never be why a gig will not start');
|
||||
});
|
||||
|
||||
// ── the prepare must never be able to BLOCK the gig (CodeRabbit, #971) ──────
|
||||
//
|
||||
// A bare `await fetch(...)` only rejects on a network error. A server that
|
||||
// accepts the connection and then never answers hangs forever — and the gig
|
||||
// would never start. That would make this optimisation the exact thing it
|
||||
// promises never to be: the reason you cannot play.
|
||||
|
||||
test('the prepare fetch is bounded — a hung server cannot block the gig', () => {
|
||||
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
||||
assert.match(fn, /AbortController/, 'the request must be abortable');
|
||||
assert.match(fn, /setTimeout\([\s\S]{0,40}abort\s*\(\s*\)/,
|
||||
'a hung request must be aborted, not awaited forever');
|
||||
assert.match(fn, /signal:\s*ctrl\.signal/, 'the signal must actually be passed to fetch');
|
||||
assert.match(fn, /clearTimeout/, 'the timer must be cleared on the happy path');
|
||||
assert.match(CAREER, /const\s+PREPARE_TIMEOUT_MS\s*=\s*\d+/, 'the ceiling must be named');
|
||||
// The button must be restored however we leave — otherwise a timeout strands
|
||||
// the poster on "Preparing set…" with Play disabled: unplayable.
|
||||
assert.match(fn, /finally\s*\{[\s\S]{0,220}btn\.disabled\s*=\s*false/,
|
||||
'the Play button must be re-enabled on EVERY path, including the abort');
|
||||
});
|
||||
@@ -77,3 +77,89 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
||||
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
||||
});
|
||||
|
||||
// ── The throttle must not starve a renderer that animates on its own clock ──
|
||||
//
|
||||
// The throttle assumes a paused chart is a still picture, so re-rendering it is
|
||||
// waste. That stopped being true when the venue landed: the 3D highway draws the
|
||||
// venue's VIDEO backdrop and its reactive crowd into the same canvas as the
|
||||
// notes, so capping paused frames capped the whole room — pausing the song
|
||||
// dropped the venue to ~10 fps ("everything around the highway drops fps").
|
||||
//
|
||||
// Renderers now opt out via an optional needsContinuousFrames(). Absent or
|
||||
// throwing must mean false, so every other renderer keeps the throttle.
|
||||
|
||||
test('paused throttle defers to a renderer that needs continuous frames', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /_rendererNeedsContinuousFrames\s*\(\s*\)/,
|
||||
'the paused throttle must consult the renderer capability');
|
||||
// The capability must GATE the early-return, not merely be called near it:
|
||||
// the throttle only applies when the renderer does NOT need every frame.
|
||||
assert.match(
|
||||
fn,
|
||||
/!\s*_rendererNeedsContinuousFrames\s*\(\s*\)[\s\S]{0,160}_PAUSED_FRAME_INTERVAL_MS[\s\S]{0,40}return;/,
|
||||
'throttle must be skipped when the renderer needs continuous frames',
|
||||
);
|
||||
});
|
||||
|
||||
test('the capability probe fails closed (absent / non-function / throwing)', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _rendererNeedsContinuousFrames()');
|
||||
assert.match(fn, /typeof\s+r\.needsContinuousFrames\s*!==\s*'function'[\s\S]{0,40}return false/,
|
||||
'a renderer without the method must keep the throttle');
|
||||
assert.match(fn, /catch[\s\S]{0,40}return false/,
|
||||
'a throwing renderer must keep the throttle, not crash the draw loop');
|
||||
assert.match(fn, /===\s*true/,
|
||||
'only an explicit true opts out — a truthy accident must not disable the throttle');
|
||||
});
|
||||
|
||||
test('3D highway claims continuous frames for BOTH sources of venue motion', () => {
|
||||
const h3d = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||
const fn = extractBlock(h3d, 'needsContinuousFrames()');
|
||||
// (1) a crowd video rolling on its own clock (career venue pack)
|
||||
assert.match(fn, /_venueCrowdVideos/, 'must key off the actual crowd video elements');
|
||||
assert.match(fn, /\.paused/, 'a paused video is a still frame');
|
||||
// (2) the venue scene's OWN fake-depth motion — backdrop breathe, haze drift,
|
||||
// warmth pulse, shimmer. Math.sin(t) in the draw loop, so it only moves while
|
||||
// we get frames, and it runs with NO pack at all. Missing this meant the venue
|
||||
// still stuttered on pause / count-in / credits whenever no video was rolling.
|
||||
assert.match(fn, /_venueEffectiveMotionMode\s*\(\s*\)\s*!==\s*'off'/,
|
||||
'the venue scene animates without any video — it must claim frames too');
|
||||
// ...and with no venue at all the paused scene IS static: the #654 GPU saving
|
||||
// must survive, so the method has to be able to return false.
|
||||
assert.match(fn, /return false;/, 'must fall through to false on a plain 3D highway');
|
||||
});
|
||||
|
||||
// ── a SUPERSEDED init is not a FAILED init ──────────────────────────────────
|
||||
//
|
||||
// Starting a gig dropped the player onto the fallback 2D highway with no venue.
|
||||
//
|
||||
// setViz('venue') installs the 3D renderer, whose init is async; the gig then
|
||||
// immediately starts its play queue, and playSong() re-initialises that same
|
||||
// renderer a tick later. A renderer mints a fresh readyPromise per init() and
|
||||
// rejects the previous one with "superseded" — but highway.js only checked that
|
||||
// the RENDERER object was unchanged, which it is. So it treated a healthy
|
||||
// re-initialising renderer as a failed one, tore it down, and reverted to 2D:
|
||||
//
|
||||
// renderer async init failure: Error: superseded
|
||||
// viz picker: reverted to default renderer (async-init-failure)
|
||||
//
|
||||
// Reproduced and fixed against the real build (venue stays selected, scene
|
||||
// active, no viz:reverted).
|
||||
|
||||
test('a superseded readyPromise must not revert the viz to 2D', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _handleAsyncInitFailure(e)');
|
||||
assert.match(fn, /readyPromise\s*!==\s*rp[\s\S]{0,40}return/,
|
||||
'a rejection from a STALE readyPromise (the renderer has since re-init\'d) must be ' +
|
||||
'ignored — otherwise a re-initialising renderer is torn down as if it had failed');
|
||||
// The renderer-identity check must survive too: a rejection belonging to a
|
||||
// renderer that has since been REPLACED is also not our problem.
|
||||
assert.match(fn, /hwState\._renderer\s*!==\s*_installedRenderer[\s\S]{0,20}return/,
|
||||
'the renderer-identity guard must remain');
|
||||
// ...and a genuine failure of the CURRENT init cycle must still revert.
|
||||
assert.match(fn, /_emitVizReverted\s*\(\s*'async-init-failure'\s*\)/,
|
||||
'a real async-init failure must still fall back to the default renderer');
|
||||
});
|
||||
|
||||
@@ -49,3 +49,63 @@ test('peekNext is null after clear', () => {
|
||||
q.clear();
|
||||
assert.strictEqual(q.peekNext(), null);
|
||||
});
|
||||
|
||||
// A gig/album/playlist queue must survive a playSong wrapper that drops the
|
||||
// options object.
|
||||
//
|
||||
// The queue tells playSong "don't clear the queue I'm driving" via
|
||||
// options.fromQueue. But a chain of plugin playSong wrappers (nam_tone,
|
||||
// midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the 3rd arg. With just the in-band
|
||||
// flag, playSong cleared the queue the instant its first song started, so a gig
|
||||
// never advanced (feedBack#… tester: "Passports does not advance in the song
|
||||
// queue"). The queue now also raises an out-of-band flag, _consumeInternalPlay(),
|
||||
// which playSong honours regardless of the wrapper chain.
|
||||
|
||||
// The real clear-guard from session.js, driven against the queue.
|
||||
function clearGuard(win, options) {
|
||||
const pq = win.feedBack && win.feedBack.playQueue;
|
||||
const queueDriven = (options && options.fromQueue)
|
||||
|| (pq && typeof pq._consumeInternalPlay === 'function' && pq._consumeInternalPlay());
|
||||
if (!queueDriven && pq) pq.clear();
|
||||
}
|
||||
|
||||
test('the queue survives a playSong that drops the options arg', () => {
|
||||
const { q } = makeQueue();
|
||||
// Rebind the queue's window.playSong to a wrapper that forwards ONLY
|
||||
// (filename, arrangement) — exactly the plugin bug — and runs the real guard.
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// Reach the same window the IIFE closed over: re-drive through the guard by
|
||||
// calling start and simulating what _play's playSong does.
|
||||
// We can't rebind the closed-over window, so instead assert the out-of-band
|
||||
// signal directly: _play sets it, and the guard consumes it.
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
// After start()->_play, the internal flag was set; the guard (which the real
|
||||
// playSong runs) must see it as queue-driven and NOT clear.
|
||||
win.feedBack.playQueue = q;
|
||||
clearGuard(win, undefined /* wrapper dropped options */);
|
||||
assert.strictEqual(q.active(), true, 'a dropped options arg must not clear the queue');
|
||||
assert.strictEqual(q.remaining(), 2, 'the queue must still have its remaining tracks');
|
||||
});
|
||||
|
||||
test('_consumeInternalPlay is one-shot — a later MANUAL play still clears', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'album' });
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// First guard call (the queue's own play) consumes the flag → no clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), true);
|
||||
// A subsequent MANUAL play (no fromQueue, flag already consumed) must clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), false, 'a manual play after the queue play must abandon the queue');
|
||||
});
|
||||
|
||||
test('fromQueue in options still works on its own (in-band path)', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'gig' });
|
||||
// consume the internal flag first so ONLY options.fromQueue is under test
|
||||
q._consumeInternalPlay();
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
clearGuard(win, { fromQueue: true });
|
||||
assert.strictEqual(q.active(), true, 'options.fromQueue alone must still keep the queue');
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ test('index.html loads venue deps before venue-scene-3d', () => {
|
||||
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||
});
|
||||
|
||||
test('syncViz activates only for venue visualization id', () => {
|
||||
test('syncViz activates only for venue visualization id, and only on the player', () => {
|
||||
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
||||
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||
@@ -216,7 +216,14 @@ test('syncViz activates only for venue visualization id', () => {
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.feedBack = { on() {} };
|
||||
// The venue is scoped to the song player: selecting Venue is a preference
|
||||
// for THAT screen, not a licence to paint the venue over anything else that
|
||||
// borrows the highway_3d renderer (Virtuoso's practice charts did exactly
|
||||
// that). syncViz therefore needs to know which screen is showing.
|
||||
const onScreen = (id) => { global.document = { querySelector: (s) => (s === '.screen.active' && id ? { id } : null) }; };
|
||||
const prevDoc = global.document;
|
||||
try {
|
||||
onScreen('player');
|
||||
venueScene.deactivate();
|
||||
venueScene.syncViz('highway_3d');
|
||||
assert.equal(global._h3dActive, false);
|
||||
@@ -224,7 +231,16 @@ test('syncViz activates only for venue visualization id', () => {
|
||||
assert.equal(global._h3dActive, true);
|
||||
assert.equal(venueScene.getState().active, true);
|
||||
assert.equal(venueScene.getState().themeId, 'small-club');
|
||||
|
||||
// ...and the same call OFF the player must not activate it.
|
||||
venueScene.deactivate();
|
||||
onScreen('virtuoso');
|
||||
venueScene.syncViz('venue');
|
||||
assert.equal(global._h3dActive, false,
|
||||
'Venue selected must NOT paint the venue onto the Virtuoso highway');
|
||||
assert.equal(venueScene.getState().active, false);
|
||||
} finally {
|
||||
global.document = prevDoc;
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Two venue bugs reported from a live career session.
|
||||
//
|
||||
// 1. Changing arrangement mid-song replayed the venue arrival flyover. The
|
||||
// camera flew in from the back of the room again, every time the player
|
||||
// switched lead -> rhythm. changeArrangement() reloads the song through the
|
||||
// normal load path, so highway.js re-emits `song:loaded` — same filename,
|
||||
// new arrangement — and the venue could not tell that from a fresh arrival.
|
||||
// The player is already on stage; the room should just carry on.
|
||||
//
|
||||
// 2. With Venue selected, the venue backdrop showed up on the VIRTUOSO highway.
|
||||
// The venue was gated purely on the viz selection, 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 it inherited the
|
||||
// crowd and the stage behind a chromatic exercise. The venue belongs to the
|
||||
// song player and nowhere else.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const crowd = require('../../static/v3/venue-crowd.js');
|
||||
|
||||
// ── 1. arrangement switch is not an arrival ────────────────────────────────
|
||||
|
||||
test('same filename = arrangement switch (no arrival flyover)', () => {
|
||||
// changeArrangement() re-emits song:loaded for the song already on stage.
|
||||
assert.equal(crowd.isArrangementSwitch('song.feedpak', 'song.feedpak'), true);
|
||||
});
|
||||
|
||||
test('different filename = a genuinely new song (flyover is correct)', () => {
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', 'b.feedpak'), false);
|
||||
});
|
||||
|
||||
test('first load of the session is an arrival, not a switch', () => {
|
||||
// No previous song -> the flyover must play.
|
||||
assert.equal(crowd.isArrangementSwitch('', 'a.feedpak'), false);
|
||||
});
|
||||
|
||||
test('a missing filename is never treated as a switch', () => {
|
||||
// Otherwise a malformed payload would silently suppress the flyover for the
|
||||
// rest of the session.
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', ''), false);
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', undefined), false);
|
||||
assert.equal(crowd.isArrangementSwitch('', ''), false);
|
||||
});
|
||||
|
||||
// ── 2. the venue belongs to the player screen ──────────────────────────────
|
||||
|
||||
const scene = require('../../static/v3/venue-scene-3d.js');
|
||||
|
||||
// Venue MUST be the selected visualization for these to mean anything: if the
|
||||
// viz were unset, shouldBeActive() would be false for the wrong reason and the
|
||||
// virtuoso assertion below would pass vacuously. Force the viz on, so the only
|
||||
// thing under test is the SCREEN gate.
|
||||
function withScreen(id, fn) {
|
||||
const prevDoc = global.document;
|
||||
const prevViz = global.v3VenueViz;
|
||||
global.v3VenueViz = {
|
||||
isVenueVisualization: (v) => String(v) === 'venue',
|
||||
getSelectedVizId: () => 'venue',
|
||||
};
|
||||
global.document = {
|
||||
querySelector(sel) {
|
||||
if (sel !== '.screen.active') return null;
|
||||
return id ? { id } : null;
|
||||
},
|
||||
};
|
||||
try { return fn(); } finally { global.document = prevDoc; global.v3VenueViz = prevViz; }
|
||||
}
|
||||
|
||||
test('guard: with Venue selected AND on the player, the venue IS active', () => {
|
||||
// If this ever fails, every "not active" test below is vacuous.
|
||||
withScreen('player', () => {
|
||||
assert.equal(scene.shouldBeActive(), true,
|
||||
'the screen gate must not break the normal case');
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is active on the player screen', () => {
|
||||
withScreen('player', () => {
|
||||
assert.equal(scene.isPlayerScreen(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is NOT active on the virtuoso screen (the bug)', () => {
|
||||
withScreen('virtuoso', () => {
|
||||
assert.equal(scene.isPlayerScreen(), false,
|
||||
'Virtuoso borrows the same highway_3d renderer — the venue backdrop ' +
|
||||
'must not follow it there');
|
||||
assert.equal(scene.shouldBeActive(), false,
|
||||
'selecting Venue is a preference for the PLAYER; it is not a licence ' +
|
||||
'to paint the venue over whatever else is using the renderer');
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is not active on any other screen either', () => {
|
||||
for (const id of ['v3-home', 'plugin-folder_library', 'settings', 'career']) {
|
||||
withScreen(id, () => {
|
||||
assert.equal(scene.shouldBeActive(), false, `venue must not be active on ${id}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('no active screen at all is not the player', () => {
|
||||
withScreen(null, () => {
|
||||
assert.equal(scene.isPlayerScreen(), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('a throwing document does not take the venue down with it', () => {
|
||||
const prev = global.document;
|
||||
global.document = { querySelector() { throw new Error('detached'); } };
|
||||
try {
|
||||
assert.equal(scene.isPlayerScreen(), false, 'must fail closed, not throw');
|
||||
} finally {
|
||||
global.document = prev;
|
||||
}
|
||||
});
|
||||
@@ -174,3 +174,176 @@ def test_double_download_409s(client, monkeypatch):
|
||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
|
||||
|
||||
|
||||
# ── gig pre-extraction (the wait between songs) ─────────────────────────────
|
||||
#
|
||||
# A feedpak is a zip: the first play of one pays for its extraction into
|
||||
# sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
|
||||
# finished a number and then sat waiting for the next one to unpack, mid-gig.
|
||||
# The setlist is known up front, so extract it all while the poster is up.
|
||||
|
||||
def _career_client_with_library(tmp_path, meta_db, dlc, cache):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import routes as career_routes
|
||||
app = FastAPI()
|
||||
career_routes.setup(app, {
|
||||
"config_dir": str(tmp_path),
|
||||
"meta_db": meta_db,
|
||||
"get_dlc_dir": lambda: dlc,
|
||||
"get_sloppak_cache_dir": lambda: cache,
|
||||
})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _write_feedpak(dlc, name, title="T"):
|
||||
"""A minimal but REAL feedpak zip, so resolve_source_dir genuinely unpacks."""
|
||||
import json as _json
|
||||
import zipfile as _zip
|
||||
p = dlc / name
|
||||
with _zip.ZipFile(p, "w") as z:
|
||||
z.writestr("manifest.json", _json.dumps({"title": title, "artist": "A", "arrangements": []}))
|
||||
return p
|
||||
|
||||
|
||||
def test_gig_prepare_extracts_every_song_up_front(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
for n in ("one.feedpak", "two.feedpak", "three.feedpak"):
|
||||
_write_feedpak(dlc, n)
|
||||
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
before = list(cache.iterdir())
|
||||
assert before == [], "nothing unpacked yet"
|
||||
|
||||
res = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["one.feedpak", "two.feedpak", "three.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["ok"] is True
|
||||
assert body["prepared"] == 3, body
|
||||
assert body["failed"] == []
|
||||
# The point of the whole exercise: the set is on disk BEFORE the first note.
|
||||
assert len(list(cache.iterdir())) == 3, "every song of the set must be unpacked"
|
||||
|
||||
|
||||
def test_gig_prepare_is_idempotent_on_a_warm_cache(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "one.feedpak")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
first = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
|
||||
second = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
|
||||
assert first["prepared"] == second["prepared"] == 1
|
||||
assert len(list(cache.iterdir())) == 1, "a re-prepare must not duplicate the unpack"
|
||||
|
||||
|
||||
def test_one_bad_feedpak_does_not_stop_the_set(tmp_path, meta_db):
|
||||
# A corrupt pak in the setlist must not block the gig: the play itself will
|
||||
# surface the error exactly as it does outside a gig. Slow beats blocked.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "good.feedpak")
|
||||
(dlc / "bad.feedpak").write_bytes(b"not a zip at all")
|
||||
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["good.feedpak", "bad.feedpak"]}).json()
|
||||
assert body["ok"] is True, "a bad pak must not fail the whole prepare"
|
||||
assert body["prepared"] == 1
|
||||
assert body["failed"] == ["bad.feedpak"]
|
||||
|
||||
|
||||
def test_gig_prepare_degrades_without_a_library(tmp_path, meta_db, client):
|
||||
# The stock fixture's context has no dlc/cache resolvers. That must be a
|
||||
# graceful no-op, not a 500 — pre-extraction is an optimisation and can
|
||||
# never be the reason a gig won't start.
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["x.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["prepared"] == 0
|
||||
|
||||
|
||||
def test_gig_prepare_empty_setlist(tmp_path, meta_db, client):
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": []})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"ok": True, "prepared": 0, "failed": []}
|
||||
|
||||
|
||||
def test_prepare_rejects_a_non_list_songs_value(tmp_path, meta_db, client):
|
||||
# A str is iterable: without the list check, "abc" would prepare three
|
||||
# one-character "songs".
|
||||
for bad in ("abc", 42, {"a": 1}, None):
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": bad})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["prepared"] == 0
|
||||
|
||||
|
||||
def test_prepare_ignores_non_string_and_blank_entries(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "good.feedpak")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["good.feedpak", "", " ", 7, None, {"x": 1}]}).json()
|
||||
assert body["prepared"] == 1
|
||||
assert body["failed"] == []
|
||||
|
||||
|
||||
def test_prepare_caps_the_setlist(tmp_path, meta_db):
|
||||
# This endpoint unpacks zips — an arbitrary caller must not be able to ask for
|
||||
# unbounded work.
|
||||
#
|
||||
# The first version of this test asserted `prepared == 0` against a fixture
|
||||
# with NO library: the endpoint exits before extraction there, so it passed
|
||||
# whether or not the cap existed. Give it a real library, ask for far more than
|
||||
# the cap, and assert the endpoint only ever considered MAX_GIG_SONGS of them.
|
||||
import routes as career_routes
|
||||
assert career_routes.MAX_GIG_SONGS <= 64
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
n = career_routes.MAX_GIG_SONGS + 50
|
||||
# None of these exist, so every song the endpoint LOOKS AT lands in `failed`.
|
||||
# That makes `failed` an exact count of how many it considered.
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": [f"missing{i}.feedpak" for i in range(n)]}).json()
|
||||
assert body["prepared"] == 0
|
||||
assert len(body["failed"]) == career_routes.MAX_GIG_SONGS, (
|
||||
f"the endpoint must consider at most MAX_GIG_SONGS "
|
||||
f"({career_routes.MAX_GIG_SONGS}), not all {n}"
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_refuses_to_escape_the_library(tmp_path, meta_db):
|
||||
# resolve_source_dir() does a bare `dlc_root / filename` with no containment
|
||||
# guard, so a crafted path would walk straight out of the library. Every
|
||||
# filename must go through _resolve_dlc_path first.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
(tmp_path / "outside.feedpak").write_bytes(b"secret")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
for evil in ("../outside.feedpak", "..\\outside.feedpak",
|
||||
"a/../../outside.feedpak", "/etc/passwd", "C:/Windows/x.feedpak"):
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": [evil]}).json()
|
||||
assert body["prepared"] == 0, f"{evil!r} must never be prepared"
|
||||
assert body["failed"] == [evil]
|
||||
# Nothing outside the library may have been unpacked.
|
||||
assert list(cache.iterdir()) == []
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""`/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}
|
||||
Reference in New Issue
Block a user