Compare commits

...
Author SHA1 Message Date
Kris Anderson b02868cbc1 fix(highway_3d): stop the lane at the hit line
The lane maps chart time to z exactly as notes do, over the window
[now - BEHIND, now + AHEAD]. That puts its near edge at +TS*BEHIND — BEHIND
seconds PAST the hit line, toward the player. Nothing is ever drawn there:
drawNote and the chord frames both clamp to Math.min(0, dZ(dt)), so notes stop
dead at z = 0. The overhang was therefore lane surface with nothing on it.

Clamp the floor geometry's near edge to the hit line. The far edge is
deliberately untouched — it still lands at -AHEAD*TS, aligned with the note
horizon, which is why the span stays AHEAD+BEHIND in the sliced path and the
clamp is applied per slice (a slice entirely past the line collapses to zero
length and is skipped before the arpeggio probe, so it costs nothing).

All four floor sites move together — the sliced lane (which also feeds both
divider loops), the fallback lane, its dividers, and the fret boundary
extension lines. They shared the identical `+ TS * BEHIND` shift; fixing only
some would leave fret lines poking past a lane that now stops.

Closes #991

Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-16 19:24:07 -04:00
3717e4338d fix(plugins): restore window.esc for out-of-tree plugins (#986)
ship-ci / ci (push) Waiting to run
app.js exported `esc` as an implicit global back when it was a classic
script. a9fce29 made it an ES module and 14b4058 carved `esc` into
js/dom.js; the window re-export list was rebuilt without it.

Out-of-tree plugins load screen.js as a classic script and call `esc()`
bare, so nothing in-tree catches the break: no-undef, a call-graph scan
and a grep all pass while the plugin throws in the field. The MIDI
plugin builds its device list with esc() inside the same try block that
catches requestMIDIAccess() failures, so the ReferenceError surfaced to
testers as "MIDI Access denied esc is not defined" — access had actually
been granted.

Pin the whole plugin-facing global surface by name, mirroring
tests/test_plugin_context_contract.py. Verified both ways against a
running app: without the fix the spec fails with "missing or not
functions: esc".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:06:52 +02:00
0b4b174d33 perf(scan): skip full library re-stat when the tree is unchanged (#979)
ship-ci / ci (push) Waiting to run
* perf(scan): skip full library re-stat when the tree is unchanged

Startup scans globbed the whole DLC tree twice (*.feedpak, *.wem) and
stat()'d every file to detect changes — ~100k filesystem round trips on
a 50k-song library, and painful on a slow NTFS-3G FUSE mount (the "big
drive churns on every launch" report).

Adds/removes/renames of songs all bump the mtime of the containing
directory (verified on the target mount), so after a full pass we persist
{reldir: mtime_ns} for every library dir (scan_dir_signature.json, keyed
by DLC path). The next scan re-stats only those dirs — a handful vs 100k
ops — and skips the entire listing/stat pass when none changed.

Blind spot: a pack rewritten in place under the same name bumps the file
mtime but not its dir's. Rare for a song library, and the manual Refresh
(/api/rescan + /api/rescan/full) now passes force=True to always do the
full pass. force threads through kick_scan -> _scan_runner and coalesces
like the rescan-pending flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scan): track directory-form songs' own dir in the signature

CodeRabbit: _library_dirs recorded only each song's parent. For a
directory-form song (loose-song folder or directory sloppak bundle),
adding/removing/replacing a file INSIDE the folder bumps that folder's
own mtime, not its parent's — so the fast path would skip a rescan it
should run. Record the song's own dir when f.is_dir(). File-form
sloppaks (a single .feedpak zip) aren't dirs, so the flat file library
is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:23:10 +02:00
Byron GamatosandGitHub 2f2a095e4c fix(venue): fly in once per set, not before every song (#978)
ship-ci / ci (push) Waiting to run
Tester, mid-gig: "the second song in the gig started when the first one ended.
But it showed the flyover intro again."

The flyover is arriving at the venue, and you arrive once. #968 stopped it
replaying on an arrangement SWITCH (same filename), but a gig's song 2 is a
genuinely different file, so it took the full-teardown path and played the
arrival flyover again — the camera flew in from the back of the room before
every track of the set.

The play queue now answers isContinuation(): false for the first song of a set
(or a standalone play — an arrival), true for song 2..N. onSongLoaded carries
the room over to the new song's loop on a continuation, and only a real arrival
plays the intro.

Verified on the built AppImage: isContinuation goes false (song 1) -> true
(song 2) across an advance, and song 2 no longer flies in.

Also confirmed NOT a bug, same session: "didn't show the author for the second
song." The credits card shows on a queue advance whenever the song carries
authors — reproduced with a song that has them as the advanced-to track. The
tester's song 2 simply had no `authors:` metadata (most auto-converted feedpaks
don't). No code change.

Tests: isContinuation across start/advance/clear, and that onSongLoaded gates the
flyover on the continuation check. Both fail on pre-fix source. JS 1214/1214.
2026-07-15 12:39:25 +02:00
Byron GamatosandGitHub e14ef64224 fix(playback): the song queue must survive a playSong wrapper that drops options (#977)
ship-ci / ci (push) Waiting to run
Tester: "Passports does not advance in the song queue."

The play queue tells playSong "don't clear the queue I'm driving" by passing
options.fromQueue. But window.playSong is wrapped by a CHAIN of plugins —
nam_tone, midi_amp, fretboard, invert_highway, tabview — and each wrapper
forwards only (filename, arrangement), silently dropping the options object. So
fromQueue never reached playSong: it cleared the queue the instant its first
song started, and a gig/album/playlist never advanced.

Reproduced on the real build via a queue.start + a hooked clear(): the queue
went inactive with 0 remaining immediately after start, and the clear stack ran
through nam_tone -> midi_amp -> invert_highway -> fretboard -> session.js.

Fixing six plugin wrappers is whack-a-mole and the next plugin re-breaks it.
Fix it at the source instead: the queue raises an out-of-band flag
(_consumeInternalPlay, one-shot) beside the wrapper chain, not through it, and
playSong's clear-guard honours it. options.fromQueue stays as the in-band path.
The flag is consumed on read so a later MANUAL play still abandons the queue.

Verified on the real build: the gig queue stays active after start and advances
on song:ended (Iron Maiden -> Blind Guardian), and a manual play still clears.

Tests drive the real clear-guard against the queue for: a dropped-options
wrapper (the bug), the one-shot manual-play-still-clears invariant, and the
in-band fromQueue path on its own. All 3 fail on the pre-fix source. JS 1211/1211.
2026-07-15 10:50:59 +02:00
Byron GamatosandGitHub 365cec1d29 fix(career): gig song selection — full-genre pool, working re-roll, and the venue pack loads (#976)
* fix(career): a gig's song pool is the whole genre, and re-roll varies it

Two tester reports, one root: the gig song pool was built from only two sets —
songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL
(`filename NOT IN song_stats`).

A song played on a DIFFERENT instrument's arrangement is in neither: it has a
stats row (so the "unplayed" filler skipped it), and its played bucket is that
other instrument's, not this passport's. It could never be gigged.

- "Metalcore says 137 songs only shows 1 in the gig list" — a library of
  metalcore all played on another instrument. Reproduced: a guitar passport with
  137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw
  was whatever handful happened to be on-instrument or truly unplayed.

- "Passport re-roll does not change songs" — a set drawn from that filler was the
  library's first N in table ORDER, every call. Re-roll re-proposes, so it
  returned the identical set. Reproduced: 3 proposals, byte-identical.

_unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of
the genre the set hasn't already picked (a stats row on some other instrument has
no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll
actually re-rolls.

Both reproduced against the real propose logic before the fix and pinned as
regression tests (both fail on the pre-fix routes.py). Full career suite green.

* fix(career): load the gig's venue pack when the gig starts

Tester: "Venue doesn't load when starting song from passport. Loads standard
particles."

crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage
pack — is reached ONLY through pushCrowdManifest, and pushCrowdManifest is
called ONLY from refresh(), the career tab's own reload. A gig navigates AWAY
from the career tab to the player, so refresh() never runs during it. startGig
set the venue override and nulled _appliedManifestVenue but never re-pushed, so
the venue visualization turned on (3D highway) while its pack never loaded — the
song played over the bare highway backdrop, or over whatever venue a previous
refresh() had left applied.

startGig now pushes the crowd manifest for the gig venue right after setting the
override, using the career state the booking screen already fetched.

This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other
caller and startGig is not it), but it is fixed by static analysis — I could not
reproduce the user-visible symptom locally because this instance happened to have
a manifest already applied from a prior refresh. On-device confirmation on a real
passport gig is still owed.

Guard test: startGig must push the manifest after setting the override (fails on
the pre-fix source). Career suite green.
2026-07-15 10:50:55 +02:00
15 changed files with 566 additions and 39 deletions
+6
View File
@@ -212,6 +212,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it.
The floor geometry now ends at the hit line; its far edge is unchanged, still
`-AHEAD*TS` at the note horizon.
- **Career passports review polish** — the passport tabs and book overlay carry
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
`role="dialog"` + `aria-modal` with focus moved to the close button on open
+137 -5
View File
@@ -51,6 +51,97 @@ from scan_worker import _relpath, _scan_one
log = logging.getLogger("feedBack.scan")
import json
# ── Directory-signature fast path ─────────────────────────────────────────────
#
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
# file to detect what changed. On a 50k-song library that lives on a slow mount
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
# the "big drive churns on every startup" report.
#
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
# holds them (verified on the target NTFS-3G mount), and so does the addition of
# a subdirectory (a new entry in its parent). So after a scan we record every
# library directory and its mtime; on the next scan we re-stat ONLY those
# directories (a handful, vs 100k file ops). If none changed, the file set is
# unchanged and the whole listing/stat pass is skipped.
#
# The one thing this cannot see is a file edited IN PLACE under the same name —
# that bumps the file's mtime but not its directory's. That is rare for a song
# library (you add and remove packs, you don't rewrite them under the same name),
# and the manual Refresh forces a full scan (force=True) for exactly that case.
def _dir_signature_file() -> Path:
return appstate.config_dir / "scan_dir_signature.json"
def _load_dir_signature() -> dict | None:
try:
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
return data
except (OSError, ValueError):
pass
return None
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
# Keyed by the DLC path so switching libraries never matches a stale
# signature. Best-effort: a failed write just means the next scan is a full
# one, never a wrong one.
try:
_dir_signature_file().write_text(
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
except OSError as e:
log.debug("scan: could not persist dir signature: %s", e)
def _library_dirs(all_songs, dlc: Path) -> set[str]:
"""Every directory whose mtime reflects an add/remove of a library song:
each song's containing directory and all of its ancestors up to the DLC
root (the root itself always included, as "."). Derived from the already-
listed songs — no extra filesystem walk. The builtin carve-outs
(tutorials-builtin / minigames-builtin) are absent because the caller
already excluded them from `all_songs`, so a minigame writing a drill there
never invalidates the fast path.
Directory-form songs (loose-song folders, directory sloppak bundles) also
record their OWN directory: a file added/removed/replaced INSIDE the folder
bumps that folder's mtime but not its parent's, so tracking only the parent
would miss an in-place change to such a song. File-form sloppaks (a single
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
stays at a handful of dir stats."""
rels = {"."}
for f in all_songs:
rel = Path(_relpath(f, dlc))
if f.is_dir():
rels.add(rel.as_posix())
parent = rel.parent
rels.add(parent.as_posix())
for anc in parent.parents:
rels.add(anc.as_posix())
return rels
def _record_dir_signature(all_songs, dlc: Path) -> None:
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
_save_dir_signature(dlc, sig)
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
unreadable — a vanished recorded dir means the tree changed, so fail to a
full scan rather than a false match."""
out: dict[str, int] = {}
for rel in rels:
try:
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
except OSError:
return None
return out
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
@@ -99,9 +190,12 @@ def _make_scan_executor():
)
def background_scan():
def background_scan(force: bool = False):
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
`force` skips the directory-signature fast path and always does the full
listing/stat pass — the manual Refresh sets it (see _dir_signature_file).
Never sets `_scan_status["running"] = False` — ownership of that flag
lives in `_scan_runner` so a `kick_scan()` racing this function's
terminal write cannot observe a stale False and start a second runner.
@@ -121,6 +215,22 @@ def background_scan():
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
# Fast path: if every library directory recorded by the last scan still has
# the same mtime, nothing was added, removed, or renamed, so the whole
# glob-and-stat pass below can be skipped (see the signature comment above).
# `force` (manual Refresh) always does the full pass. Seeding above is
# idempotent — it only writes when a builtin is missing — so it does not
# perturb the mtimes on a settled library.
if not force:
stored = _load_dir_signature()
if stored is not None and stored.get("dlc") == str(dlc):
current = _stat_dirs(dlc, stored["dirs"].keys())
if current is not None and current == stored["dirs"]:
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
len(current))
return
# Listing can fail on macOS without Full Disk Access, or on Docker if the
# path isn't shared. Report the failure explicitly rather than silently
# appearing to scan nothing.
@@ -223,6 +333,9 @@ def background_scan():
to_scan.append((f, mtime, size, dlc))
if not to_scan:
# Full pass completed with the DB already up to date — record the tree
# signature so the next startup can take the fast path.
_record_dir_signature(all_songs, dlc)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
return
@@ -247,6 +360,9 @@ def background_scan():
_scan_status["done"] += 1
_scan_status["current"] = fname
# Record the tree signature after a completed full pass so the next startup
# can skip it when nothing has changed.
_record_dir_signature(all_songs, dlc)
log.info("Scan complete: %d songs cached", len(to_scan))
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
@@ -255,6 +371,9 @@ _scan_kick_lock = threading.Lock()
_scan_rescan_pending = False
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
# manual Refresh bypasses the directory-signature fast path.
_scan_force_next = False
# Handles to the running scan / enrichment worker threads. Both use the shared
@@ -265,9 +384,15 @@ _scan_rescan_pending = False
_scan_thread: threading.Thread | None = None
def kick_scan() -> bool:
def kick_scan(force: bool = False) -> bool:
"""Request a library rescan, single-flight + coalescing.
`force` skips the directory-signature fast path for the resulting pass (the
manual Refresh uses it so an in-place same-name edit — the one thing the
fast path can't see — is always picked up). A forced request that coalesces
onto a running or queued scan keeps the force intent: the pass is forced if
ANY pending request asked for it.
Returns True if a new scan thread was started, False if one was already
running. In the latter case a follow-up pass is queued and runs as soon
as the current scan finishes so files landing mid-scan (e.g. an upload
@@ -275,8 +400,10 @@ def kick_scan() -> bool:
until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up.
"""
global _scan_rescan_pending, _scan_thread
global _scan_rescan_pending, _scan_thread, _scan_force_next
with _scan_kick_lock:
if force:
_scan_force_next = True
if _scan_status["running"]:
_scan_rescan_pending = True
return False
@@ -290,10 +417,15 @@ def kick_scan() -> bool:
def _scan_runner():
"""Run _background_scan, then re-run if requests arrived mid-scan."""
global _scan_rescan_pending
global _scan_rescan_pending, _scan_force_next
while True:
# Consume the force flag for THIS pass; a forced request queued mid-scan
# sets it again for the follow-up.
with _scan_kick_lock:
forced = _scan_force_next
_scan_force_next = False
try:
background_scan()
background_scan(force=forced)
except Exception:
log.exception("background scan failed unexpectedly")
+29 -17
View File
@@ -522,27 +522,39 @@ def _current_venue():
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
pool = [
{"filename": filename, "title": title or filename, "artist": artist or ""}
for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and filename not in exclude
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _validate_pack_dir(pack_dir: Path):
@@ -836,7 +848,7 @@ def setup(app, context):
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
+14
View File
@@ -1192,7 +1192,21 @@
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* viz optional — restore stays intact */ }
}
// Push the gig's venue pack to the crowd layer NOW.
//
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
// and pushCrowdManifest is called only from refresh() — the career
// tab's own reload. A gig navigates AWAY from the career tab to the
// player, so refresh() never runs during it, and setting the override
// above does nothing on its own. The result the testers saw: the venue
// visualization turns on (3D highway) but its crowd/stage pack never
// loads, so the song plays over the bare highway backdrop ("standard
// particles"), or over whatever venue a previous refresh() happened to
// leave applied. We just changed the override to this gig's venue, so
// re-push for it. _state is the career state the booking screen already
// fetched; guard for the rare null.
_appliedManifestVenue = null;
if (_state) pushCrowdManifest(_state);
_ppGigRun = {
songs: prop.songs,
venue_id: prop.venue_id,
+27 -9
View File
@@ -12614,8 +12614,19 @@
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
if (!b) continue;
const z0 = dZ(dt0) + TS * BEHIND;
const z1 = dZ(dt1) + TS * BEHIND;
// The lane STOPS AT THE HIT LINE (z = 0) — issue #991. The
// slice window starts BEHIND seconds in the past, so the
// first slices map to positive z, i.e. past the hit line
// toward the player. Nothing is ever drawn there: notes and
// chord frames clamp to Math.min(0, dZ(dt)), so that strip
// is lane with nothing on it. Clamp the NEAR edge only —
// the far edge stays at dZ(AHEAD+BEHIND)+TS*BEHIND = -AHEAD*TS,
// aligned with the note horizon, exactly as before.
const z0 = Math.min(0, dZ(dt0) + TS * BEHIND);
const z1 = Math.min(0, dZ(dt1) + TS * BEHIND);
// Slice lies entirely past the hit line -> zero length, nothing
// to draw. Skip before the arp probe so it costs nothing.
if (z0 === z1) continue;
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
? arpeggioLaneOuterRailLaneSlice(
dt0, dt1, now,
@@ -12764,9 +12775,13 @@
divMin = dMin;
divMax = dMax;
// Same fix: extend to AHEAD+BEHIND so far edge = -AHEAD*TS.
const laneLen = TS * (AHEAD + BEHIND);
const zLane = -laneLen / 2 + TS * BEHIND;
// Far edge at -AHEAD*TS (the note horizon), near edge at the
// hit line (z = 0) — the lane does not run past it toward the
// player, where nothing is ever drawn (#991). Spanning
// AHEAD+BEHIND and shifting by +TS*BEHIND put the near edge at
// +TS*BEHIND; spanning AHEAD alone keeps the same far edge.
const laneLen = TS * AHEAD;
const zLane = -laneLen / 2;
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
mLaneOdd.opacity = laneOp;
@@ -12786,7 +12801,8 @@
}
if (highwayIntensity > 0.05) {
const divLen = TS * (AHEAD + BEHIND);
// Matches the lane above: ends at the hit line (#991).
const divLen = TS * AHEAD;
const yPos = boardY + 0.03 * K;
const divOp2 = 0.02 + highwayIntensity * 0.1;
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
@@ -12799,7 +12815,7 @@
for (let f = fDivA; f <= fDivB; f++) {
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
const div = pLaneDivider.get();
div.position.set(xFret(f), yPos, dZ(0) - divLen * 0.5 + TS * BEHIND);
div.position.set(xFret(f), yPos, -divLen * 0.5);
div.material = mLaneDivider;
div.scale.set(1, 1, divLen);
div.renderOrder = 2;
@@ -12818,8 +12834,10 @@
// ── Fret boundary extension lines ─────────────────────────
if (mLaneDividerExt && fretDividersVisible) {
const extLaneLen = TS * (AHEAD + BEHIND);
const extZMid = -extLaneLen / 2 + TS * BEHIND;
// Same hit-line stop as the lane (#991) — otherwise these lines
// would be the only floor geometry still running past it.
const extLaneLen = TS * AHEAD;
const extZMid = -extLaneLen / 2;
const extYPos = boardY + 0.03 * K;
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
for (let f = 0; f <= NFRETS; f++) {
+5 -2
View File
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
@app.post("/api/rescan")
def trigger_rescan():
"""Manually trigger a library rescan."""
if not scan.kick_scan():
# force=True: a manual Refresh must skip the directory-signature fast path —
# it is the escape hatch for the one change dir mtimes can't see (a pack
# rewritten in place under the same name).
if not scan.kick_scan(force=True):
return {"message": "Scan already in progress"}
return {"message": "Rescan started"}
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
# delete_missing() prunes anything genuinely gone at the end.
meta_db.conn.execute("UPDATE songs SET mtime = -1")
meta_db.conn.commit()
if not scan.kick_scan():
if not scan.kick_scan(force=True):
return {"message": "Scan already in progress"}
return {"message": "Full rescan started"}
+27 -2
View File
@@ -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,15 @@ window.feedBack.playQueue = (function () {
}
return {
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
// True when the current song is a queue ADVANCE (song 2..N of a set),
// false for its first song or a standalone play. The venue uses this to
// fly in once on arrival at the set, then continue the room between
// songs instead of replaying the arrival flyover every track.
isContinuation: function () { return active() && idx > 0; },
// 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
@@ -2297,11 +2319,14 @@ configureHost({
currentFilename: () => currentFilename,
});
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
// script and called esc() back when app.js was one too and it was an implicit
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
Object.assign(window, {
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
+12 -3
View File
@@ -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');
+21 -1
View File
@@ -529,7 +529,27 @@
_loadingLoop = null;
_fadingLoop = null;
if (_venueActive && _manifest) {
if (!playIntro()) showLoop(machine.current, FADE_MS);
// The flyover is ARRIVING at the venue, and you arrive once. Songs
// 2..N of a set (a gig / album / playlist) are a NEW song but the
// SAME arrival — the camera should not fly in from the back of the
// room before every track (tester: "it showed the flyover intro
// again" on a gig's second song). Continue the room to the new song's
// loop; only a first-song / standalone arrival flies in.
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
else if (!playIntro()) showLoop(machine.current, FADE_MS);
}
}
// Is this song load a continuation of a play queue (a set already in
// progress), rather than an arrival? True for song 2..N of a gig/album/
// playlist. The queue owns the answer; treat any error / absent queue as
// "not a continuation" so a standalone play still flies in.
function _isSetContinuation() {
try {
const q = window.feedBack && window.feedBack.playQueue;
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
} catch (_) {
return false;
}
}
@@ -0,0 +1,63 @@
// The window globals are a THIRD-PARTY CONTRACT. Pin them.
//
// Out-of-tree plugins load their screen.js as a CLASSIC script and call these
// as bare globals. Nothing in core reads most of them, so a call-graph scan,
// ESLint's no-undef, and a grep all come back clean while the plugin breaks in
// the field. This is the frontend twin of tests/test_plugin_context_contract.py
// — same reasoning, same literal-list rule.
//
// This guard is retroactive: `esc` was an implicit global back when app.js was
// a classic script, went module-scoped in a9fce29, and got carved into
// js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt
// without it, and the MIDI plugin's device list threw "esc is not defined" for
// testers — reported as "MIDI Access denied", because the ReferenceError landed
// in a try/catch meant for permission failures.
//
// WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from
// app.js would assert the code equals itself. The point is that a human has to
// look at a diff and consciously agree to change the contract.
import { test, expect } from '@playwright/test';
const PLUGIN_GLOBALS = [
'_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl',
'_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal',
'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop',
'deleteSelectedLoop', 'esc', 'exportDiagnostics', 'exportSettings', 'filterFavorites',
'filterLibrary', 'fullRescanLibrary', 'goFavPage', 'handleSliderInput',
'hideScanBanner', 'importSettings', 'loadPlugins', 'loadSavedLoop',
'loadSettings', 'onSectionPracticeModeChange', 'openEditModal', 'persistSetting',
'pickDlcFolder', 'pinCurrentArrangementDefault', 'playSong', 'previewDiagnostics',
'previewEditArt', 'renderGridCards', 'renderTreeInto', 'rescanLibrary',
'retuneSong', 'saveCurrentLoop', 'saveSettings', 'seekBy',
'setAvOffsetMs', 'setFavView', 'setInstrumentPathway', 'setLibView',
'setLibraryProvider', 'setLoopEnd', 'setLoopStart', 'setMastery',
'setSpeed', 'setViz', 'showScreen', 'sortFavorites',
'sortLibrary', 'syncLibrarySong', 'toggleAllArtists', 'toggleAllFavoriteArtists',
'toggleLibFilters', 'togglePlay', 'toggleSectionPracticePopover', 'uiPrompt',
'updatePlugin', 'uploadSongs',
'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage',
];
test('plugin-facing window globals are all callable', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const missing = await page.evaluate(
(names) => names.filter((n) => typeof (window as any)[n] !== 'function'),
PLUGIN_GLOBALS,
);
expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]);
});
// The plugin call site that actually broke: esc() interpolated into a template
// string. A global that exists but doesn't escape is its own bug.
test('window.esc escapes HTML metacharacters', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const escaped = await page.evaluate(() => (window as any).esc('<img src=x onerror=alert(1)>'));
expect(escaped).not.toContain('<img');
expect(escaped).toContain('&lt;');
});
+27
View File
@@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
// Degrades without the crowd layer (PR1 not merged / older desktop).
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
});
// feedBack#… (tester): "Venue doesn't load when starting song from passport.
// Loads standard particles." crowd.setManifest(venue) is reached ONLY through
// pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh() (the
// career tab's own reload). A gig navigates away from that tab, so refresh()
// never runs during it — the venue viz turns on but its crowd/stage pack never
// loads. startGig must push the manifest itself after setting the override.
test('startGig pushes the crowd manifest for the gig venue', () => {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8');
const start = src.indexOf('async function startGig(');
assert.ok(start !== -1, 'startGig not found');
const open = src.indexOf('{', src.indexOf(')', start));
let depth = 1, i = open + 1;
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
const fn = src.slice(start, i);
// The override is set, then the manifest must be (re)pushed for it.
const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/);
const pushIdx = fn.search(/pushCrowdManifest\s*\(/);
assert.ok(overrideIdx !== -1, 'startGig must set the venue override');
assert.ok(pushIdx !== -1,
'startGig must push the crowd manifest — refresh() (its only other caller) ' +
'never runs during a gig, so the venue pack would never load');
assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue');
});
+77
View File
@@ -49,3 +49,80 @@ 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');
});
// isContinuation(): true for song 2..N of a set, false for the first song / a
// standalone play. The venue uses it to fly in once on arrival, then carry the
// room between songs instead of replaying the arrival flyover every track
// (tester: "it showed the flyover intro again" on a gig's second song).
test('isContinuation is false on the first song, true after advancing', () => {
const { q } = makeQueue();
assert.strictEqual(q.isContinuation(), false, 'idle queue is not a continuation');
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
assert.strictEqual(q.isContinuation(), false, 'the FIRST song of a set is an arrival, not a continuation');
q.advance();
assert.strictEqual(q.isContinuation(), true, 'song 2 is a continuation — no re-flyover');
q.advance();
assert.strictEqual(q.isContinuation(), true, 'song 3 too');
q.clear();
assert.strictEqual(q.isContinuation(), false, 'a cleared queue is not a continuation');
});
+19
View File
@@ -117,3 +117,22 @@ test('a throwing document does not take the venue down with it', () => {
global.document = prev;
}
});
// The arrival flyover must NOT replay for songs 2..N of a set. onSongLoaded
// consults the play queue: a continuation (gig/album/playlist song 2+) carries
// the room over with a loop crossfade, only an arrival plays the intro.
test('a set continuation carries the room over instead of re-flying-in', () => {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-crowd.js'), 'utf8');
const start = src.indexOf('function onSongLoaded(');
const open = src.indexOf('{', src.indexOf(')', start));
let depth = 1, i = open + 1;
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
const fn = src.slice(start, i);
const contIdx = fn.search(/_isSetContinuation\s*\(\s*\)/);
const introIdx = fn.search(/playIntro\s*\(/);
assert.ok(contIdx !== -1, 'onSongLoaded must consult the set-continuation signal');
assert.ok(introIdx !== -1, 'the intro must still exist for a real arrival');
assert.ok(contIdx < introIdx, 'the continuation check must gate the flyover — a set song 2+ must not fly in');
});
+27
View File
@@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
res = client.post("/api/plugins/career/drill-state",
json={"byNode": {}, "goldImprov": blob})
assert res.status_code == 413
def test_gig_includes_songs_played_on_another_instrument(client, meta_db):
# feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list".
# A song played on a DIFFERENT instrument's arrangement has a stats row, so it
# was excluded from the unplayed filler — and its played bucket is that other
# instrument's, not this passport's — so it fell into a gap and could never be
# gigged. A guitar passport with a library of bass-played metalcore got a 404.
for i in range(137):
meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
assert res.status_code == 200, "a full library of the genre must never 404"
assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap"
def test_gig_reroll_changes_the_set(client, meta_db):
# feedBack#… (tester): "Passport re-roll does not change songs". A set drawn
# from the filler used to be the library's first N in table order, every time.
for i in range(40):
meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore")
sets = set()
for _ in range(5):
r = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"])))
assert len(sets) > 1, "re-roll must be able to produce a different set"
+75
View File
@@ -137,6 +137,81 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
assert "ignore.zip" not in seen
# ── 2b. directory-signature fast path (skip the full re-stat) ────────────────
def test_dir_signature_fast_path_skips_unchanged_tree(tmp_path, scan_server):
"""After a full scan records the library-dir signature, a second scan with
an unchanged tree takes the fast path and does NOT re-glob/extract but a
forced scan (manual Refresh) always does the full pass, and a new song
(which bumps the dir mtime) reverts to a full pass on its own."""
import unittest.mock as mock
dlc = tmp_path / "dlc"
dlc.mkdir()
(dlc / "a.feedpak").write_bytes(b"")
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
scan = importlib.import_module("scan")
seen: list[str] = []
def mock_extract(f, dlc_dir):
seen.append(f.name)
return {"title": f.name, "artist": "", "album": ""}
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
# 1) first pass: full scan, extracts a.feedpak (+ seeded builtins),
# records the signature
scan.background_scan()
assert "a.feedpak" in seen
assert scan._dir_signature_file().exists()
# 2) unchanged tree: fast path — no glob, no extraction at all
seen.clear()
scan.background_scan()
assert seen == []
assert scan.status()["stage"] == "complete"
# 3) a new song bumps the dlc mtime → signature mismatch → full pass
# picks it up on its own (no manual Refresh needed for adds)
(dlc / "b.feedpak").write_bytes(b"")
seen.clear()
scan.background_scan()
assert "b.feedpak" in seen
# 4) force=True (Refresh) bypasses the fast path even on a settled tree:
# with the signature now current, a plain scan skips, a forced one lists
seen.clear()
scan.background_scan() # fast path
assert seen == []
forced_listed = []
real_delete_missing = scan.appstate.meta_db.delete_missing
def _spy(files):
forced_listed.append(set(files))
return real_delete_missing(files)
with mock.patch.object(scan.appstate.meta_db, "delete_missing", new=_spy):
scan.background_scan(force=True)
assert forced_listed, "force=True must run the full listing pass"
def test_dir_signature_tracks_directory_form_song_own_dir(tmp_path):
"""A directory-form song (loose folder / directory bundle) records its OWN
directory in the signature, so an in-place file change inside it which
bumps that folder's mtime but not its parent's invalidates the fast path.
A file-form sloppak (a plain .feedpak zip) is not a dir and adds nothing."""
scan = importlib.import_module("scan")
dlc = tmp_path / "dlc"
(dlc / "packs").mkdir(parents=True)
loose = dlc / "packs" / "my_loose_song" # directory-form song
loose.mkdir()
zipped = dlc / "packs" / "zipped.feedpak" # file-form song
zipped.write_bytes(b"")
rels = scan._library_dirs([loose, zipped], dlc)
assert "packs/my_loose_song" in rels, "directory-form song must track its own dir"
assert "packs" in rels and "." in rels
assert "packs/zipped.feedpak" not in rels, "a file-form sloppak is not a tracked dir"
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
@pytest.fixture()