Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Fable 5 523feb161e feat(venue-crowd): toggleable crowd sound reactions on mood transitions
Rank up → the venue's cheer sample, rank down → boos, gated behind the
feedBack-venue-crowd-sfx setting (default off), rate-limited by the
existing transition hysteresis and suppressed during the intro. Sounds
ship per-venue in the pack manifest (sfx: {up, down}).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:09:17 +02:00
byrongamatosandClaude Fable 5 4abdfb5f5c fix(venue-crowd): fall back to the loop when the intro fails to load
Codex preflight: a failed/timed-out intro left the song with no crowd
loop at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:25:26 +02:00
byrongamatosandClaude Fable 5 99812b8ed3 feat(venue-crowd): flyover intro with crowd-ambience ducking
On song:loaded, an optional pack intro plays once: a camera flyover video
(idle layer, one-shot) with bar-crowd ambience audio that ducks out on
song:play, near the flyover's landing, or at handoff — whichever first.
Machine commits and stingers defer during the intro; stop()/song-change
abort it. Packs without an intro behave as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:21:22 +02:00
byrongamatosandClaude Fable 5 9d8fda6d46 fix(venue-crowd): always detach load listeners, gate only the callback
Codex preflight: superseded loads left canplaythrough/error listeners
attached to the persistent video elements — unbounded growth over a
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:09:00 +02:00
byrongamatosandClaude Fable 5 663e8ff4a1 fix(venue-crowd): abort stale stinger state on song load
Codex preflight: a stinger straddling a song change could fade back into
the previous song's layer or flush its pending loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:06:04 +02:00
byrongamatosandClaude Fable 5 f362c9a083 fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value
Codex preflight: Number(null) is 0, so idle HUD resets overwrote
_lastAccuracyPct before stats:recorded consumed it, suppressing the
end-of-song stinger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:03:48 +02:00
byrongamatosandClaude Fable 5 be78b4f29e fix(venue-crowd): cancel in-flight fade when a stinger preempts it
Codex preflight: the orphaned ramp kept pushing the mix toward the layer
whose src the stinger had just replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:01:28 +02:00
byrongamatosandClaude Fable 5 f9a57ba044 fix(venue-crowd): reset crowd mood to neutral on song load
Codex preflight: a song ending in ecstatic/bored left the next song's
crowd stuck in that mood until the hysteresis window passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:58:15 +02:00
byrongamatosandClaude Fable 5 c83fec267e fix(venue-crowd): reset active layer with mix on stop
Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a
restart flashed layer 0's stale frame until the new loop loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:54:20 +02:00
byrongamatosandClaude Fable 5 c73ff96dfe fix(venue-crowd): keep rear video layer opaque during crossfades
Two half-transparent layers let the static bg plate bleed through (~25%
at mid-fade) — visible as a flash of the old still image on every state
transition. The crossfade is now always the front layer fading over an
opaque rear layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:51:41 +02:00
byrongamatosandClaude Fable 5 286a24214f fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer
Codex preflight round 6: the loop crossfade's completion callback could
still run between a stinger's start and its canplaythrough, promoting the
stinger's layer to active and pausing the real loop — it now bails when
the fading loop was preempted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:35:17 +02:00
byrongamatosandClaude Fable 5 9178959dbd fix(venue-crowd): generation-gate stinger handlers; recrop on video size change
Codex preflight round 5: (1) an ended/timeout handler orphaned by stop()
could fire into a later stinger's lifecycle on the reused element — handlers
now detach unconditionally and carry a generation token; (2) the renderer
only re-applied cover-crop on camera aspect changes, so a src swap with a
different intrinsic size kept stale repeat/offset — it now recrops when
videoWidth/Height change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:33:12 +02:00
byrongamatosandClaude Fable 5 de10e81259 fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap
Codex preflight round 4: (1) idleLayer() still points at the fading-in
layer during a crossfade, so a stinger firing mid-fade overwrote the new
loop with nothing requeued — the fading loop is now tracked and requeued
like an in-flight load; (2) swapping venue packs while active now goes
through stop() so _stopGen invalidates the old manifest's in-flight loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:29:38 +02:00
byrongamatosandClaude Fable 5 1172bc30cb fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events
Codex preflight round 3: (1) a failed/timed-out stinger left a deferred
loop switch queued forever; the failure path now flushes it. (2)
stats:recorded only carries {filename, arrangement}, so the end-of-song
reaction now uses the accuracyPct from the song's last
v3:live-performance-state event (a real percentage) instead of a field
that never existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:26:47 +02:00
byrongamatosandClaude Fable 5 5e5d892a63 fix(venue-crowd): per-video load tokens + unbind renderer on stop
Codex preflight round 2: (1) the global load token let a stinger cancel a
committed loop load on the other layer — tokens are now per-element, and a
stinger preempting an in-flight loop on its own layer requeues that loop
for when the stinger ends; (2) setManifest(null)/deactivate left the last
crowd frame bound and visible over the static plate — stop() now unbinds
both layers from the renderer and zeroes the mix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:24:11 +02:00
byrongamatosandClaude Fable 5 cab652d145 fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions
Codex preflight P2s: (1) videos created before highway_3d registered its
globals never reached the backdrop planes — binding is now idempotent and
retried from start/perf-event/re-activation paths; (2) a crowd-state
switch committing while a stinger played was dropped because the machine
had already advanced — it is now deferred and played when the stinger ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:21:19 +02:00
byrongamatosandClaude Fable 5 78dbb039b7 feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1)
Two crossfading video backdrop planes in the highway_3d venue background
style, driven by a new venue-crowd.js state machine that maps
v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic)
with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers
on streak milestones and end-of-song accuracy. Inert without a venue pack
manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the
static bg plate behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:17:58 +02:00
70dbe45e27 refactor(server): carve builtin-content seeding into lib/builtin_content.py (R3b) (#900)
lib/builtin_content.py (321 lines moved). server.py 2,418 -> 2,098.

The calibration/diagnostic sloppaks and the starter library: _copy_builtin_packs,
_write_builtin_pack, the two seed helpers, their source tables, and the seed marker.

━━━ THE ONE SIGNATURE CHANGE, AND WHY THE CARVE IS UNSAFE WITHOUT IT ━━━

server.py has:

    def _feedBack_server_root() -> Path:
        return Path(__file__).resolve().parent

That is correct IN server.py: the repo root in dev, resources/feedBack when bundled — the
tree that actually holds docs/ and data/.

Move that body into lib/ unchanged and it keeps working, silently, and returns lib/. There
is no docs/diagnostics under lib/, so every seed would find nothing, log "source missing"
at debug, and return. Nothing raises. Nothing fails. The starter library simply never
appears, and the calibration sloppak is never seeded — on a fresh install, in the field.

A verbatim move whose MEANING changed because __file__ did.

So this module cannot compute a root: `server_root` is a PARAMETER, and server.py — the
only place that legitimately knows where it lives — passes it in. The trap is now
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
root that way; the two seed helpers now do too.)

Everything else is byte-identical. CONFIG_DIR is read late as appstate.config_dir and the
DLC root through dlc_paths._get_dlc_dir — the same seam every router in lib/routers/ uses,
late-bound because tests monkeypatch it.

━━━ PYFLAKES FOUND THREE MISSING IMPORTS THE TESTS WOULD HAVE FOUND ONE AT A TIME ━━━

The moved code uses `secrets`, `stat` and `tempfile`; none was in my import block. Each is
a NameError on a live path. `python3 -m pyflakes` names all three in one shot — this is the
Python twin of the no-undef gate that guarded every frontend carve, and it should run on
every server.py slice from here.

It also flagged a PRE-EXISTING one I deliberately did not touch: server.py's
TuningProviderRegistry.get_merged() calls `logger.exception(...)` in an except handler and
there is no `logger` in the module (it is `log`). So a raising tuning provider takes down
the merged-tunings call for everyone, with a NameError naming the wrong problem. Filed as
issue #899 rather than smuggled into a carve whose whole value is being behaviour-neutral.

The constants lost their underscore prefix: they cross a module boundary now (the seed
tests read them), so `_BUILTIN_STARTER_SOURCES` was a lie.

pytest 2397, pyflakes 0, Codex 0. Guarded by the plugin_context contract test (#898).

Refs #48

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:50:50 +02:00
1cef01d02c test(plugins): pin the plugin_context contract before carving server.py (R3b) (#898)
tests/test_plugin_context_contract.py (3 tests). No production code changes.

server.py is about to be carved apart around startup_events(), and `plugin_context` — the
20-key dict handed to every plugin's setup() — is built inline inside it. Issue #48 flagged
this while planning the split and asked for exactly this guard:

    "Plugin context[...] are passed as live references into already-loaded plugins.
     Refactoring must preserve the exact callables — moving them to a new module is fine,
     but renaming or wrapping them breaks third-party plugins. We'd want a
     'plugin context unchanged' assertion in CI."

It never got written. Writing it FIRST, because a key silently dropped or renamed by a move
is invisible to every other test in the suite — nothing in-tree reads most of these — and
would break plugins at runtime, in the field.

This is the backend's version of the window contract, and the frontend carve just taught me
what that costs: 43 of library.js's exports were referenced ONLY from app.js's top-level
window block, invisible to any call-graph scan, and trusting the scan would have shipped a
dead A-Z rail with CI fully green. A contract only external code reads has to be pinned BY
NAME, before the move, not after.

THE SURFACE IS BIGGER THAN server.py's DICT. Shipped plugins read `log` and `load_sibling`,
and neither is in it — plugins/__init__.py layers them on per-plugin. A test pinning only
server.py's 18 keys would have missed both.

━━━ CODEX CAUGHT ME WRITING A VACUOUS ASSERTION ━━━

My first identity test built a dict locally and called setup() on it — asserting
`dict(x)['k'] is x['k']`, which is trivially true and blind to everything the loader does.
[P2], and correct. It now drives the REAL plugins.load_plugins() with a probe plugin, which
matters: the loader DOES deliberately wrap one key (register_library_provider is scoped
per-plugin so a plugin cannot forge owner attribution and impersonate another). The test
pins that single intentional exception so it cannot quietly become two.

Codex then caught [P2] number two: my hand-rolled teardown restored only PLUGINS_DIR and
LOADED_PLUGINS, while load_plugins() also mutates sys.path, sys.modules and
PENDING_PLUGINS — order- and environment-dependent. tests/test_plugins.py already had a
fixture that does this properly, so `reset_plugin_state` moved to tests/conftest.py: ONE
copy, shared, rather than a second that will drift.

BITE-TESTED IN FIVE DIRECTIONS — drop a key, rename a key, drop a per-plugin key, wrap
extract_meta in the loader (all key names intact, identity broken), and remove the
register_library_provider scoping (the impersonation guard). Each fails.

pytest 2399, Codex 0.

Refs #48

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:29:48 +02:00
756588678b fix(plugins): make a module plugin actually re-evaluate on reload (#879) (#897)
A plugin reload silently did nothing for scriptType:"module" plugins. ES modules are
evaluated ONCE PER URL PER DOCUMENT, so re-inserting a <script type="module"> whose src
the module map has already seen fires `load` without re-running the body — and the loader
then recorded the reload as applied. A no-op that reported success.

THE ISSUE UNDERSTATES IT. #879 says "upgrades are fine — a new version yields a new URL".
That is true of screen.js and FALSE of the plugin. I drove a real browser through
install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0), counting evaluations of src/main.js:

    ONE.

Not three, not two. The upgrade re-runs the one-line screen.js shim at its new ?v= URL;
the shim does `import './src/main.js'`; a relative specifier resolves against the base URL
WITH THE QUERY DROPPED; that is the same URL as before; the module map hands back the
already-evaluated v1.0.0 module. The plugin's own code never re-ran. Busting the entry
point cannot fix this, whatever token you hang off it.

So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. From there
'./src/main.js' resolves to /api/plugins/<id>/g/<n>/src/main.js — every relative import
inherits it, at every depth, for free. No import-specifier rewriting (which could never
see `import(expr)` anyway). Same browser drive after the fix: THREE evaluations.

Keyed on the plugin ID, not id@version: EVERY re-load of a module plugin needs a fresh
path, not just a rollback. First load keeps the stable ?v= URL, so the ETag/304 live-edit
caching the R0 rails depend on is untouched. Classic-script plugins are not affected and
never take a /g/ path.

━━━ A PATH REWRITE, NOT TWO MIRRORED ROUTES ━━━

Codex caught this, and it was right. The token shifts the BASE URL, so EVERYTHING the
module graph resolves relatively moves with it — not only imports.
`new URL('../assets/worklet.js', import.meta.url)` from /api/plugins/x/g/1/src/main.js
resolves to /api/plugins/x/g/1/assets/worklet.js. Mirroring only screen.js and src/ would
have fixed imports and 404'd every asset, worklet and wasm file the graph reaches — and
would have broken again the next time someone added a plugin route.

So the /g/<token> segment is STRIPPED BEFORE ROUTING. Every plugin route, present and
future, works under the prefix with no extra wiring. The token is opaque and never joined
into a filesystem path, so containment still rests entirely on the same safe_join.

Codex then caught a [P3] in that: eagerly re-encoding raw_path with latin-1 raises
UnicodeEncodeError on a valid plugin file like src/工具.js, 500ing a request the plain
route serves fine. raw_path is informational and Starlette routes on scope["path"], so the
mutation is simply gone — and leaving raw_path as the client sent it is more truthful for
logs anyway.

TESTS. tests/js/plugin_module_rollback.test.js (5) + 8 in test_plugin_src_route.py:
identical bytes under the prefix, the whole graph one and two levels deep, ASSETS (the
Codex [P2]), every plugin route, non-ASCII filenames (the [P3]), an opaque token, and
containment asserted as PARITY with the un-prefixed route rather than a guessed 404 —
`../screen.js` legitimately 200s on both, because the URL normalises before routing.
All bite-tested: reverting the fix fails the rollback tests, disabling the rewrite fails
the asset tests.

Two harnesses re-anchored on `script.src = _pluginScriptUrl(` — the URL literal they keyed
on now lives in the helper, further down the file, so their slice ran off the end.

node 1045, pytest 2404, ESLint 0, Codex 0.

Closes #879

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:19:59 +02:00
bd830328f0 refactor(app): carve the library out of app.js (R3a) (#896)
static/js/library.js (1,988) + static/js/library-state.js (29) — bodies VERBATIM.
app.js 6,313 -> 4,451.

THE BIGGEST SLICE OF THE CARVE: 145 declarations, ~1,900 lines, 30% of what was left.
The grid, the artist tree, the A-Z rail, filters, pagination, selection, favourites, the
scan banner, and the library-provider plumbing.

A LOW module: it imports only leaves (./dom.js, ./format.js, ./library-state.js,
./tuning-display.js — all four import nothing themselves) and needs ZERO host hooks. It
calls nothing in app.js. That is not luck; it is why this cluster was picked. Two entry
points that WOULD have dragged the playback core in were left behind in app.js:

  * syncLibrarySong     reaches showScreen/playSong
  * _handleLibArrowNav  Enter on a selected row plays the song

Both are one hop from the library, and app.js is the root, so it imports from both sides
for free. Pulling them in swallows playSong, showScreen and the whole remaining core — I
measured it: the closure jumps from 145 declarations to 189.

library-state.js holds exactly FIVE fields. An imported binding is read-only, and of the
library's outward bindings only these five are genuinely WRITTEN from outside — by
showScreen, deleteSongFromModal and syncLibrarySong, none of which can move in. The other
23 are read-only from outside, so they stay plain exports (ES live bindings mean app.js
still sees every reassignment).

━━━ THE EXPORT LIST NEARLY SHIPPED A DEAD A-Z RAIL ━━━

59 exports — and 43 of them CANNOT be found by a call-graph scan. They are referenced only
from app.js's TOP-LEVEL statements: the Object.assign(window, {...}) contract and the
scattered window.X = X lines, which live outside every function, so a closure walk over
declarations never sees them. Among them are the four handler names app.js composes AT
RUNTIME into onclick="" strings — filterTreeLetter, filterFavTreeLetter, goTreePage,
goFavTreePage — the library A-Z rail and its pagination. No static tool can see those at
all. Had I trusted the call-graph, the rail would have died silently on click with nothing
failing in CI.

━━━ AND MY OWN SCANNER LIED ━━━

The cycle-risk pass reported "(none)" for this carve. It was wrong, and it could not have
been right: a dangling `else if` bound to an inner `if` instead of the outer chain, so its
`imported` map was ALWAYS empty and the check reported clean no matter what. A guard that
cannot fail is worse than no guard. Fixed, and it then found the real edges — dom.js,
format.js, tuning-display.js, library-state.js. All four are leaves, so the carve is
genuinely acyclic; I just now know it instead of assuming it.

(The AST rewriter had its own trap: `MAP[name]` with an object literal and name ===
'constructor' hits Object.prototype.constructor — truthy — and it happily rewrote
`constructor(id)` into `L.function Object() { [native code] }(id)`. Every identifier in
the file is looked up, so the lookup must not see the prototype chain. It is a Map now.)

TESTS. legacy_shim_hits SPLIT (loadLibraryProviders + setLibraryProvider -> the module;
syncLibrarySong stayed in app.js). v3_library_refresh now reads app.js AND the module,
rather than being re-pinned to whichever file happens to hold the emit this week.

VERIFIED. A/B against origin/main in two browsers: the whole window contract, cards render,
grid/tree/sort/filter/clear round-trip — and, specifically, the A-Z rail: 28 onclick
handlers composed at runtime, identical on both, and a real .click() on a letter works.
IDENTICAL on all 33 + 7 probes, no new page errors.

pytest 2396, node 1040/1040, host contract 2/2, ESLint 0 (no-cycle clean).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:30:30 +02:00
09f7e450a5 refactor(app): give formatTime a home — a leaf format.js, one fewer host hook (R3a) (#895)
static/js/format.js (17). One function. Retires the formatTime hook: 12 -> 11.

WHY A MODULE FOR ONE FUNCTION. formatTime was a host hook — loops.js and
section-practice.js both reached back through the seam for it. It is ALSO, by pure
accident of who calls it, inside the dependency closure of the library carve that comes
next. Leaving it there would have made loops.js and section-practice.js import the
LIBRARY in order to format a timestamp — nonsense, and a cycle waiting to happen.

Same rule as the transport carve: a hook is a cycle you agreed to live with; an import is
a dependency you actually have. formatTime has a real owner. It just isn't app.js, and it
certainly isn't the library. Give it a home and both consumers import it directly.

A leaf on purpose. Anything else that turns out to be a shared pure formatter belongs
here too; nothing does yet (I checked — formatBadge, _safeImageUrl and _fetchJsonOrThrow
have no callers outside the library), so nothing else is here.

node 1040/1040, host contract 2/2, ESLint 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:27:17 +02:00
8bec8d2466 refactor(app): carve the playback transport out of app.js — and RETIRE 8 host hooks (R3a) (#894)
static/js/transport.js (377) — bodies VERBATIM. app.js 6,643 → 6,316.

THIS IS THE FIRST CARVE THAT SUBTRACTS HOOKS INSTEAD OF ADDING THEM.

Every carve before this one added host hooks: a module pulled out of app.js still had
to call back into it. But four modules were all reaching through the seam for the SAME
handful of names — _audioSeek, _audioTime, setPlayButtonState, _songEventPayload,
jucePlayer. Those names have an owner, and it isn't app.js. Give them one, and the
consumers import them directly:

    count-in.js           5 hooks -> 0     (host import deleted)
    juce-audio.js         4 hooks -> 0     (host import deleted)
    loops.js              6 hooks -> 4
    section-practice.js  10 hooks -> 7
    ----------------------------------------------------------
    configureHost()      20 hooks -> 12

A hook is a cycle you agreed to live with. An import is a dependency you actually have.
Prefer the import whenever the name has a real owner.

_audioSeekGen now stays PRIVATE. It has exactly one writer — _resetAudioSeekState(),
which moved with it — so readers get audioSeekGen() and nobody outside can desync it.
Strictly better than the hook it replaces, which handed out a getter and left the writer
behind in app.js.

THE SCAN HAD A HOLE, AND IT BIT. Picking the carve by dependency closure over app.js's
own top-level decls said this cluster was downward-closed. It wasn't:
_currentPlaybackSnapshot reads loopA/loopB — which live in ./js/loops.js, and loops.js
imports transport. The scan saw nothing, because loopA STOPPED BEING an app.js decl the
moment loops.js was carved out. Any dependency scan of a partly-carved monolith has to
resolve the imports too, or it will confidently hand you a cycle. Added that pass; it
found exactly one back-edge, and _currentPlaybackSnapshot stays in app.js (as does
restartCurrentSong, which calls _cancelCountIn). app.js is the root — it imports both
sides for free.

TESTS. Four harnesses retargeted (play_button_reroute_guard, song_event_payload,
song_seek -> transport.js; playback_app_adapter SPLIT, since
_installPlaybackTransportAdapter stayed behind).

The two CENSUS tests — "≥8 song:* emit sites", "every seek callsite passes a reason" —
now scan app.js AND every static/js/*.js, not one file. Pointed at a single file, their
count silently shrinks as code leaves, which reads as "someone deleted an emit" or, worse,
passes while genuinely missing sites. Both bite-tested: stripping a _songEventPayload()
from an emit and adding a reason-less _audioSeek() each fail the suite.

VERIFIED. A/B against origin/main, real song, real playback: song:play payload is exactly
{audioT, chartT, perfNow, time}; song:seek carries reason "seek-by" with finite from/to;
all five song:* events fire; seekBy advances the clock; restartCurrentSong returns to zero;
the play button's aria-pressed tracks state. IDENTICAL on all 21 probes, zero page errors.

pytest 2396, node 1040/1040, host contract 2/2, ESLint 0 (no-cycle clean), Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:26:35 +02:00
8d0e270345 refactor(app): carve the JUCE/desktop audio shims out of app.js (R3a) (#893)
* refactor(app): carve resume-session out of app.js (R3a)

static/js/resume-session.js (157) — the snapshot taken when you leave a song and the
pill that offers it back. Bodies VERBATIM. app.js 7,727 → 7,601.
Fifth slice out of the strongly-connected core. ONE hook (playSong) + a
currentFilename getter.

S.pendingResume JOINS THE CONTAINER — on demand, exactly as intended. app.js WRITES
it (playSong({ resume }) arms it; the song:ready listener consumes it) while this
module reads it, so it cannot be a plain export: an imported binding is read-only.
Same reason isPlaying is there. The container grows one field per carve that needs
it, never speculatively.

THE CONTRACT TEST CAUGHT THE MISSING HOOK, again on a path nothing executes:
"playSong is read by a module but never wired by app.js — it would throw at runtime".
Second time it has caught a real wiring gap the moment it appeared.

A REAL TRAP, worth remembering: I first did the S.pendingResume rewrite by feeding
acorn's identifier RANGES from node into python, and it corrupted the file
(`_pS.pendingResume null;`). **Acorn's offsets are UTF-16 code units; Python's string
indices are code points.** static/app.js contains emoji, so every offset past one
drifts. Do an AST-driven rewrite in the SAME language that produced the offsets.
`node --check` caught it; a silent version of that bug is very easy to imagine.

VERIFIED. A/B against origin/main in two browsers, real song: the window API
(resumeLastSession / _snapshotResumeSession / _readResumeSession /
_clearResumeSession), snapshot, read-back, and clear — IDENTICAL, zero page errors.
HONEST LIMIT: my probe never got the snapshot to actually PERSIST (there is a guard
beyond the 3s minimum position that a scripted playSong does not satisfy), so that
path is verified only as identical-to-main, not as observed-working. The real
coverage is tests/browser/resume-session.spec.ts, which drives the flow properly.

Zero harnesses broke. pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean),
tailwind clean, Codex 0.

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

* refactor(app): carve the JUCE/desktop audio shims out of app.js (R3a)

static/js/juce-audio.js (994) — bodies VERBATIM. app.js 7,603 → 6,643.
THE LARGEST SINGLE SLICE of the whole carve phase: 960 lines, ~13% of what was left.

Three self-installing IIFEs:
  _installJuceEngineRoutingWatcher (444)  routes a song to the JUCE engine or HTML5 as
                                          the desktop output enters/leaves exclusive/ASIO
  _installRendererBusFeeder        (337)  feeds the highway renderer bus from whichever
                                          transport is actually running
  _installJuceAudioElementShim     (156)  patches audio.play/pause so the rest of the app
                                          keeps talking to the <audio> element while JUCE
                                          owns the transport

They EXPORT NOTHING — all three publish through `window.*` (_juceMode,
_reevaluateJuceRouting, _reevaluateRendererBus, …). So app.js needs only a
side-effect import plus the one binding it actually uses
(_resetJuceAudioShimChain, which the shim IIFE assigns).

THE ORDERING QUESTION, CHECKED RATHER THAN ASSUMED. Importing this module runs the
IIFEs EARLIER than before: imports evaluate ahead of app.js's body, and therefore
ahead of configureHost(). A hook read at IIFE-execution time would THROW. So I walked
the AST at IIFE-body depth to see what they actually touch when they run: nothing but
listener registration, and `audio.play`/`audio.pause` patching — and `audio` is itself
an imported module now. Verified in the browser: both are patched on the carved build
exactly as on main, which proves the shim installs correctly at its new, earlier point.
(Had I got this wrong, host.js throws loudly rather than silently misbehaving — which
is the whole reason it has no no-op defaults.)

VERIFIED. A/B against origin/main in two browsers: the entire window.* surface the
IIFEs publish (_juceMode, _juceOutputIsExclusive, _reevaluateJuceRouting,
_reevaluateRendererBus, _clearJuceRerouteMemo), audio.play/pause patched, a real song
loading and togglePlay driving the public mirror — IDENTICAL, zero page errors.

Harnesses: juce_engine_reroute (19 tests) + renderer_bus_feeder (13) slice the IIFEs by
signature — retargeted, and each sandbox gains a `host` object routed at its EXISTING
stubs so every assertion holds unchanged. test_plugin_runtime_idempotence is SPLIT: 3 of
its 4 source-asserts stayed in app.js, the sm.emit('song:resume') one moved.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

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-11 22:44:03 +02:00
dc429ecd16 refactor(app): carve the player controls out of app.js (R3a) (#891)
static/js/player-controls.js (229) — the speed + mastery sliders and the four
playback-preference reads (autoplay-exit, up-next, countdown-before-song,
confirm-exit). Bodies VERBATIM. app.js 7,914 → 7,727.

The fourth slice out of the strongly-connected core, and by far the easiest: ONE
hook (handleSliderInput) and NO shared mutable state. The three groups are the same
surface — the controls under the highway — and the preference reads are the
one-line localStorage lookups half of app.js consults before deciding whether to
auto-start, show the Up Next pill, run a count-in, or confirm on exit. They travel
with the controls that set them.

Zero missed members on the first build (the no-undef pass was clean), which is the
first time that has happened in this phase.

TWO HARNESSES ARE SPLIT, and both taught something:

  * speed_reset spans BOTH files — playSong (app.js) resets the speed controls
    (module). Its presence GUARDS still read `src.includes('function setSpeed')`
    against app.js, so once the code moved they silently evaluated FALSE and the
    helpers were quietly dropped from the sandbox. A guard that disables itself is
    worse than no guard. Repointed at the file the code actually lives in.

  * Its `host.handleSliderInput` stub had to route at the sandbox's EXISTING spy,
    not a fresh `() => {}`. The test asserts the slider was actually refreshed
    (`deepEqual(__sliderInputs, ['speed-slider'])`); a fresh stub swallows the call
    and the assertion passes VACUOUSLY. Same failure mode as a no-op host default —
    the thing this whole seam design exists to prevent.

  * autoplay_exit is split too: _autoplayExitEnabled moved, but the auto-exit
    machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin) stayed.

VERIFIED. A/B against origin/main in two browsers, real song: setSpeed(0.75) ->
playbackRate 0.75; applySpeedPreset(100) -> 1; the speed slider; setMastery;
setAutoplayExit / setCountdownBeforeSong / setShowUpNext — IDENTICAL, zero page errors.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:12:34 +02:00
11f8c36b61 refactor(app): carve count-in (and the song-credits overlay) out of app.js (R3a) (#890)
static/js/count-in.js (389) — bodies VERBATIM. app.js 8,223 → 7,913.
The third slice out of the strongly-connected core, and the first that WRITES
shared state rather than only reading it. #889's container is what makes it possible.

  imports: loops (setLoop/loopA/loopB — a count-in inside an A-B loop must begin at
           A), audio-el, player-state, host
  hooks  : _audioSeek, setPlayButtonState, _songEventPayload, togglePlay + a
           jucePlayer getter
  Nothing imports count-in back — app.js and section-practice both reach it through
  the seam — so the graph stays acyclic.

app.js's autoplay path used to reach IN and set this module's credits timers itself
(_creditsTimer, _creditsHideOnPlay) and read _countingIn. It cannot now, and should
not have to, so the module exports the OPERATIONS instead — armCreditsHideOnPlay(),
scheduleCreditsHide(), holdCreditsThen(start), isCountingIn() — and owns its own
timer invariants. Third time this has happened (section-practice's resetSelection,
loops' state) and each time the constraint produced better code than was there
before: the module keeps its own promises instead of trusting a caller 6,000 lines
away to zero the right fields.

THE no-undef GATE FOUND FIVE MISSED MEMBERS, one at a time: showSongCreditsOverlay
and startSongCountIn (my name regex matched startCountIn, not startSongCountIn),
then _creditLineLabel, _CREDITS_MAX_MS, and _CREDIT_ROLE_VERBS. A call-graph closure
does not see a const table; only the undefined-symbol pass does.

AND A REAL TRAP: I computed _CREDIT_ROLE_VERBS's span against the ALREADY-MODIFIED
app.js and applied it to the clean one — the line numbers had drifted, so the slice
would have cut somewhere else entirely. Recomputed every span from the clean file
with acorn. Never carry line numbers across an edit.

VERIFIED. A/B against origin/main in two browsers with a real song: playback state,
the public feedBack.isPlaying mirror, audio position, cancel-count-in — IDENTICAL,
zero page errors. Unit coverage moved with the code: loop_restart's count-in
cancellation-token test and the 5 song_credits_overlay tests now read count-in.js;
loop_restart's sandbox gains a `host` object routed at its EXISTING stubs, so every
assertion is unchanged.

HONEST LIMIT: I could not make the count-in OVERLAY actually render headlessly —
its autoplay path needs a fresh-load _pendingAutostart that a scripted playSong()
never arms. Behaviour is identical to main on every probe and the unit tests cover
the logic, but the on-screen 1-2-3-4 and the credits card want a human look.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:05:22 +02:00
5fb28d5c5a refactor(app): lift the shared player state onto a container (R3a) (#889)
static/js/player-state.js — one exported object, two fields. app.js's 70 reference
sites rewritten. Provably a no-op; nothing shrinks.

WHY NOW. Every slice carved out of app.js so far only ever READ the state it shared
(loopA/loopB, _audioSeekGen, currentFilename), so a read-only getter hook was enough
and no container was needed — twice I checked and twice I got away with it. That
runs out at count-in: it genuinely WRITES `isPlaying` (it starts and stops playback,
4 sites) and `lastAudioTime` (2). `import { isPlaying }` then `isPlaying = true`
THROWS — an imported binding cannot be assigned to. So the state has to live on an
object: `S.isPlaying = true` is a property write, and works from any module holding
the same S. Same shape stems, studio, and editor all converged on.

DELIBERATELY SMALL. app.js has ~104 top-level `let` scalars; lifting all of them is
a ~977-site rewrite for no benefit, because most are private to one cluster and
travel with it. Only what a carved module must WRITE goes here. Add on demand.

THE REWRITE IS AST-DRIVEN, NOT TEXTUAL — and that is not fussiness. Of 100 textual
occurrences of these two names, only 70 resolve to the module binding:
  * 22 are member accesses (`someObj.isPlaying`, `window.feedBack.isPlaying`)
  * 4 are the LOCAL PARAMETER of `function setPlayButtonState(isPlaying)` — a blind
    replace yields `function setPlayButtonState(S.isPlaying)`
  * 1 is an object key
  * 2 are shorthand properties `{ isPlaying }`, which must become
    `{ isPlaying: S.isPlaying }` — and acorn gives a shorthand's key and value the
    SAME range, so rewriting both produced `isPlaying: S.isPlaying: S.isPlaying`
    until I deduped by range
A find-and-replace corrupts all 29. The rewrite walks the AST, skips shadows, member
properties and keys, and replaces identifier RANGES.

`window.feedBack.isPlaying` — the PUBLIC mirror — is a different thing and is
untouched. Two test sandboxes stub it; those were left alone deliberately.

VERIFIED WITH REAL PLAYBACK. A/B against origin/main in two browsers, real song:
togglePlay -> the public mirror goes true -> false -> true across two toggles, the
audio element's paused state follows, seekBy works — IDENTICAL, zero page errors.

Harnesses: 8 vm-sandbox suites slice playback code out of app.js and now see
S.isPlaying — juce_engine_reroute, loop_restart, play_button_reroute_guard,
playback_app_adapter, song_restart, song_seek, speed_reset, and the python
idempotence source-assert. Each gets the same container in its sandbox; every
assertion is unchanged.

pytest 2396, node 1040/1040, ESLint 0, tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:52:51 +02:00
cb236e6c04 refactor(app): carve the A–B loop out of app.js (R3a) (#888)
static/js/loops.js (261) — bodies VERBATIM. app.js 8,421 → 8,224.
The second slice out of the strongly-connected core.

It OWNS the loop state — loopA, loopB, _loopMutationGen. Nothing outside writes
them: restartCurrentSong() looked like it did, but it declares its own local `let
loopA/loopB` shadows, so the module-level bindings only ever change in setLoop /
setLoopStart / setLoopEnd / clearLoop. All four move here. No state container.

DIRECTION IS THE WHOLE DESIGN. loops and section-practice are mutually dependent —
the SCC in miniature. clearLoop() must drop section-practice's selection, and
practiceSection() must call setLoop(). Both edges cannot be imports or no-cycle
(rightly) rejects it. So:

    section-practice  ->  reaches loops through the HOST SEAM (host.setLoop, …)
    loops             ->  imports section-practice DIRECTLY

section-practice is the higher-level feature — a consumer of loops, not the reverse
— so it is the one that takes the indirection. app.js hands the loop module's
exports across into the seam for it. Graph stays acyclic; no-cycle passes.

THE CONTRACT TEST EARNED ITS KEEP IMMEDIATELY. It failed on the first build with
"these hooks are wired by app.js but no module reads them: playSong". My dependency
scan had counted a mention of playSong() inside a COMMENT in loops.js as a real
call. Wired but unused is precisely the "fossil of a rename" case the test exists
for — and it caught it on a path no test executes.

VERIFIED BY DRIVING BOTH SIDES OF THE SEAM. A/B against origin/main in two browsers,
real song loaded:
  * setLoop(5,12) -> true; getLoop() -> 5,12 — IDENTICAL
  * clearLoop() (loops -> section-practice, a direct import) -> getLoop() ->
    null,null — IDENTICAL
  * onPhraseNext() (section-practice -> loops, ACROSS THE SEAM) -> ok — IDENTICAL
  * loadSavedLoop / saveCurrentLoop / deleteSelectedLoop on window — IDENTICAL
  * zero page errors either side. An unwired hook throws, so a live app is itself
    proof the seam is wired.

Harness: loop_api extracts the loop helpers by signature — retargeted to loops.js,
`export` stripped for the vm sandbox, and the sandbox's existing _audioSeek /
_audioTime / formatTime spies are now routed through a `host` object so every
assertion holds unchanged, just through the indirection the real code uses. It is
SPLIT: one test still reads app.js for the window.feedBack API surface, which stayed.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:32:31 +02:00
64f04565e2 refactor(app): the host seam + carve section practice out of app.js (R3a) (#887)
static/js/host.js (99) + static/js/section-practice.js (1,214).
app.js 9,461 → 8,409.

THE FIRST SLICE OUT OF THE STRONGLY-CONNECTED CORE. What is left in app.js is not
a tree, it is a cycle: seeding a dependency closure from section-practice, from
loops, from count-in, or from the JUCE seek shim all return the SAME 178-function
set, and setLoop() and practiceSection() call each other directly. No closure-based
carve can cut it at any seed. So it is cut BY NAME, and the calls back into app.js
go through a host seam.

  61 functions + its own 24 _sectionPractice*/_sectionParents* scalars (read nowhere
  else) move out. 11 hooks come back in. 4 of those are read-only GETTERS —
  loopA/loopB/_audioSeekGen/_loopMutationGen are only ever READ here, never written,
  so app.js keeps owning them and NO state container is needed (a 977-site lift
  avoided).

app.js used to reach IN and reset the module's state by hand (clearLoop() zeroed the
selection; changeArrangement() invalidated the parent count). It cannot now — an
imported binding is read-only — so those are exported as resetSelection() and
invalidateParentCount(). Strictly better: the module owns its own invariants instead
of trusting two callers on the far side of the file to zero the right three fields.

═══ THE SILENT-NO-OP PROBLEM, SOLVED ═══
The obvious host seam is an object of no-op defaults. That is a TRAP and we walked
into it once: the plugin loader's seam defaulted populateVizPicker to `() => {}`, so
a dropped wiring line would have left the viz picker quietly not refreshing with NO
test, boot check, or bot noticing. Two layers stop it here:

  1. RUNTIME — host.js is a Proxy with NO defaults and NO stubs. Reading an unwired
     hook THROWS. An unwired hook cannot degrade into a no-op because there is
     nothing to degrade INTO. configureHost() also rejects a non-function at WIRE
     time, and refuses to run twice.

  2. STATIC — tests/js/host_contract.test.js asserts the hooks the modules USE are
     exactly the hooks app.js WIRES. This is the layer that matters: a runtime throw
     only fires if the broken path executes, and the whole danger of a seam is the
     paths that never run in a smoke test. VERIFIED TO BITE in all three drift
     directions: drop a hook from configureHost -> fails; rename host.setLoop in the
     module -> fails; wire a hook nobody uses -> fails.

Writing that guard took three tries and each failure is instructive: (a) the
configureHost regex anchored `});` at column 0, ran past the indented close, and
swallowed app.js's 66-name window contract — 77 "hooks"; (b) an import-stripping
regex with `[\s\S]*?` ate 14,000 characters INCLUDING the drift the bite test was
meant to catch — a guard with a hole is worse than no guard, because you trust it;
(c) `host.js'` in the import path backtracked from `js` to a "hook" called `j`.
The bite tests are what surfaced all three.

CODEX FOUND A REAL RACE [P2]. configureHost() was inside the async boot function,
after several awaits — but the window handlers (onPhraseNext, …) go live during
app.js's SYNCHRONOUS module evaluation. A user clicking one in that window would hit
"[host] … was read before configureHost() ran". It is now a bare top-level statement
sitting immediately before the window contract, so the seam is always wired before a
handler can be reached. Verified live: invoking a handler 1.2s in — well before the
boot awaits settle — works.

VERIFIED. A/B against origin/main in two browsers with a REAL song loaded: popover
toggle, practice-mode change, phrase-next, and clearLoop (all of which cross the seam
— setLoop/clearLoop/_audioTime/loopA/loopB) — IDENTICAL, zero page errors. Since an
unwired hook throws, a live app is itself proof the seam is wired.

Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a
resetSelection SPY (not a stub) and ASSERTS it fires — the guarantee is still tested,
just through the seam.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:58:41 +02:00
f53d566dbc refactor(app): give the <audio> element a module of its own (R3a) (#886)
static/js/audio-el.js — one exported const. app.js's diff is 5 lines.
This is a HINGE, not a carve: nothing shrinks, but almost everything left in
app.js is blocked behind it.

WHY. `audio` is `document.getElementById('audio')` with 162 references in app.js
and 173 outside any one cluster. Every remaining cluster measured — settings,
app-updates, count-in (208 fns), exit-confirm (212), library-render (220) — lists
`audio` among its inbound symbols, because they all touch playback and playback
reaches for the element directly. A module that needs it cannot import app.js to
get it (that closes a cycle and fails import-x/no-cycle), so today the only way to
carve any of them would be a host seam — the exact thing #878 had to build and
#880 had to tear out.

WHY IT'S SAFE. `audio` is a `const` and is NEVER reassigned anywhere in core, so a
read-only import binding is exactly right and no state container is needed. The
162 call sites are untouched — the binding keeps its name, it is just imported
instead of declared. (Contrast the reassigned scalars — isPlaying, _avOffsetMs —
which CANNOT be shared this way: an imported binding cannot be written to. Those
still need containers, and that is the next problem, not this one.)

TIMING. app.js is <script type="module">, so it evaluates after the HTML is parsed
and its imports evaluate just before its body — the same moment app.js used to run
this exact lookup. If the element had not been in the document, `audio` would be
null and app.js's top-level `audio.addEventListener(...)` calls would throw and
kill the module. They don't.

VERIFIED WITH REAL PLAYBACK, not a boot check. A/B against origin/main in two
browsers: app alive with zero page errors (which is itself the proof the import
resolved), #audio is an AUDIO element, togglePlay/seekBy live, and playSong() on a
real library song sets audio.src and the element reports a duration — IDENTICAL on
both sides.

pytest 2396, node 1038/1038, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:44:01 +02:00
b5dd585d25 refactor(app): carve settings backup + plugin updates out of app.js (R3a) (#885)
Two leaves, one PR. app.js 9,651 → 9,457.

static/js/settings-io.js (155) — exportSettings + importSettings, the Settings
backup bundle. Imports nothing. The two-phase rationale comment (server first and
atomic; then a best-effort localStorage merge) is the contract and moved with the
code.

plugin-updates → INTO static/js/plugin-loader.js, not a module of its own.
checkPluginUpdates + updatePlugin are plugin MANAGEMENT; they belong with the code
that loads plugins. A new file for 50 lines would have been a file for its own
sake.

All four are inline handlers on the Settings screen and already in app.js's window
contract, so app.js re-exposes the imported bindings unchanged.

VERIFIED BY DRIVING BOTH FLOWS. A/B against origin/main in two browsers:
  * checkPluginUpdates() -> hits the API and settles the button back to
    "Check for Updates" — IDENTICAL
  * exportSettings() -> POSTs /api/settings/export and writes
    "Exported feedBack-settings…" to #backup-status — IDENTICAL (fetch intercepted
    so the assertion is on the real call, not a stub)
  * all four resolve on window — IDENTICAL
  * zero console/page errors either side

Zero harnesses broke. pytest 2396, node 1038/1038, ESLint 0, tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:42:54 +02:00
d47883c5e5 refactor(app): carve the tuning-display helpers out of app.js (R3a) (#884)
static/js/tuning-display.js (228 lines) — bodies VERBATIM. app.js 9,838 → 9,650.
A LEAF: imports nothing.

Tuning NAME resolution (Drop D / Eb Standard / raw-offset fallback), bass
detection, effective string count, and the target FREQUENCIES + note names the
tuner checks against. Pure functions over a small MIDI/note-name table; the 3
_TUNING_* tables are read nowhere else and move in.

NOT A SLICE — a node-level extract. The span 2309-2535 INTERLEAVES the functions
with the `window.*` / `window.feedBack.*` assignments that publish them, and one
of those is `window.feedBack = window.feedBack || {}` — the BUS BOOTSTRAP, not
tuning code at all. Every ExpressionStatement stays exactly where it was; only the
16 functions and 3 tables move. app.js re-exposes the imported bindings from the
same lines, so the public surface and its ordering are untouched (constitution II
names window.feedBack).

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors,
              tuning-display }

HARNESSES — 4 broke, and 3 of them broke in the SAME informative way: they sliced
app.js from `function isBassArrangement(` UP TO the marker
`window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;` — an end-marker
that (correctly) stayed behind in app.js. The module is now nothing BUT the tuning
helpers, so there is no block to slice: they read it whole and strip `export ` so
the vm sandbox still evaluates it as a script.
  tuner_auto_open is SPLIT — its autoplay-gate test still reads app.js, so it keeps
  APP_JS and gains TUNING_JS. Retargeting its path wholesale (my first attempt)
  silently pointed the autoplay test at the wrong file.

VERIFIED BY DRIVING THE CONTRACT. A/B against origin/main in two browsers, through
the real window surface: displayTuningName -> "E Standard" / "Drop D" /
"Eb Standard", parseRawTuningOffsets('-2,0,0,0,0,0') -> [-2,0,0,0,0,0],
isBassArrangement, effectiveStringCount, displayTuningTargets, and
window.feedBack.displayTuningName / .songTuningContext — IDENTICAL on both, zero
console/page errors either side.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:37:44 +02:00
ebbfc8da6f refactor(app): carve the highway string-colours out of app.js (R3a) (#883)
static/js/highway-colors.js (601 lines) — bodies VERBATIM.
app.js 10,415 → 9,837. Under 10k.

DECOMPOSED, not sliced. The "settings" blob measured 47 fns / 19 inbound and was
not carvable as-is. Seeding the closure from a FUNCTION (initHighwayColors) found
only 18 fns and left 4 HWC_* constants used outside it — i.e. the seed was wrong,
not the cluster. Re-seeding from the STATE (every function touching HWC_*/`_hwc*`)
found the true cluster: 45 top-level nodes, lines 2751-3331, CONTIGUOUS, with ZERO
foreign nodes inside the span.

  INBOUND: 0.  EXPORTS: 2 (initHighwayColors, hwcInitSettingsUI).

The other 43 symbols — the HWC_* tables, the 12 presets, the theme store, the
share codec, the picker handlers, the window.feedBack.highwayColors facade — are
used nowhere else in core and stay private. No inline on*= handlers here (the
Settings buttons are wired by addEventListener inside hwcInitSettingsUI), so
nothing needed re-exposing on window. The three bus listeners register inside
initHighwayColors, which app.js calls — not at module top level — so no ordering
change.

THE no-undef GATE EARNED ITS KEEP. My closure said INBOUND=0; the module actually
uses `uiPrompt` (the "name this theme" prompt). It was missed because uiPrompt is
no longer an app.js DECLARATION — it's an IMPORT BINDING (from #882's dom.js), and
I was collecting declarations only. `no-undef` with typeof:true caught it.
  => Lesson for the next carve: seed `tops` from ImportDeclaration bindings too.
  => And it VALIDATES carving dom.js early: this module just imports uiPrompt from
     it. Had dom.js still been stranded in app.js, this carve would have needed a
     host seam.

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors }
  plugin-loader -> viz
  highway-colors -> dom
  viz, diagnostics-export, dom -> (leaves)

VERIFIED BY DRIVING THE FACADE. A/B against origin/main in two browsers:
window.feedBack.highwayColors installed, identical method surface, 12 presets,
identical default slot colours, and a share-code encode→decode round-trip
returning #112233 — IDENTICAL on both, zero console/page errors either side.

Harnesses: highway_colors_facade + highway_string_colors retargeted (both
brace-extract blocks out of the source by signature).

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:22:14 +02:00
58 changed files with 9430 additions and 6770 deletions
+378
View File
@@ -0,0 +1,378 @@
"""Builtin content seeding: the calibration/diagnostic sloppaks and the starter library.
Carved VERBATIM out of server.py (R3b) — with ONE deliberate signature change, and it is
the whole reason this module is safe.
━━━ WHY THE ROOT IS A PARAMETER ━━━
server.py had `_feedBack_server_root()` = `Path(__file__).resolve().parent`. That is
correct *in server.py*: the repo root in dev, resources/feedBack when bundled — the tree
that actually holds docs/ and data/.
Move that body here unchanged and it keeps working, silently, and returns `lib/`. There is
no docs/diagnostics under lib/, so every seed would quietly find nothing and log "source
missing" — a verbatim move whose meaning changed because `__file__` did. Nothing would
fail; the starter library would just never appear.
So this module CANNOT compute a root: it takes `server_root` as a parameter, and server.py
— the only place that legitimately knows where it lives — passes it in. The trap is now
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
root this way; the two seed helpers now do too.)
Everything else is byte-identical. `log` is this module's own logger under the same
`feedBack.` hierarchy, and CONFIG_DIR is read late as `appstate.config_dir` — see appstate.py
for why those reads must be late-bound (tests monkeypatch it).
"""
import logging
import os
import secrets
import shutil
import stat
import tempfile
from pathlib import Path
import appstate
from dlc_paths import _get_dlc_dir
log = logging.getLogger("feedBack.builtin_content")
BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
(
"feedBack-diagnostic-basic-guitar.sloppak",
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
),
]
def builtin_diagnostic_filename() -> str:
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
the onboarding challenge target (spec 010)."""
return f"{BUILTIN_DIAGNOSTIC_SUBDIR}/{BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
def _copy_builtin_packs(
root: Path,
dest_dir: Path,
sources: list[tuple[str, str]],
label: str,
update_existing: bool = True,
) -> int:
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
bundled). A pack is copied when its destination is missing. Never deletes
user files; refuses to follow a symlinked seed directory or destination and
refuses to clobber a non-regular destination (any would let a copy escape
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
prefixes every log line.
``update_existing`` controls what happens when a *regular* destination file
already exists: when True (diagnostic seed) a bundle copy newer than the
destination refreshes it; when False (one-time starter content) an existing
file is always left as-is so the user's copy is never overwritten.
Returns the number of ``sources`` that are present at their destination
afterwards (freshly seeded, refreshed, or already current) — so callers can
tell whether every pack made it. A skip (missing source, symlink/non-regular
refusal, copy error) does not count.
"""
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
# and copies would land at the link target, outside the DLC tree. The
# per-file symlink guard below cannot catch this.
if dest_dir.is_symlink():
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
return 0
dest_dir.mkdir(parents=True, exist_ok=True)
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
# dest_dir *after* the check above cannot redirect the per-file stat /
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
# os.replace accepts dir_fd on POSIX even though it isn't listed in
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
dir_fd = None
if (
hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
and os.open in os.supports_dir_fd
and os.rename in os.supports_dir_fd
):
try:
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
except OSError as exc:
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
return 0
try:
present = 0
for dest_name, rel_source in sources:
source = root / rel_source
if not source.is_file():
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
continue
# lstat the destination without following symlinks. Pinned by dir_fd
# this resolves within the real seed dir, immune to a parent swap.
try:
if dir_fd is not None:
dstat = os.lstat(dest_name, dir_fd=dir_fd)
else:
dstat = os.lstat(dest_dir / dest_name)
dest_exists = True
dest_islink = stat.S_ISLNK(dstat.st_mode)
except FileNotFoundError:
dest_exists = False
dest_islink = False
except OSError as exc:
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
continue
# Refuse to seed through a symlink at the destination name.
if dest_islink:
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
continue
# A non-regular destination (directory, fifo, …) the user placed
# there: never clobber it, and never count it as present — otherwise
# a one-time seed would mark itself done without a real pack on disk.
if dest_exists and not stat.S_ISREG(dstat.st_mode):
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
continue
if dest_exists:
# A regular file is already there. One-time seeds (starter
# content) must never overwrite the user's copy; refreshing
# seeds (diagnostics) replace it only when the bundle is newer.
if not update_existing:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
try:
src_mtime = source.stat().st_mtime
except OSError as exc:
log.warning("%s: cannot stat source %s: %s", label, source, exc)
continue
if src_mtime <= dstat.st_mtime:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
action = "updated"
else:
action = "seeded"
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
present += 1
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
else:
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
return present
finally:
if dir_fd is not None:
os.close(dir_fd)
def _write_builtin_pack(
source: Path,
dest_dir: Path,
dest_name: str,
dir_fd: int | None,
) -> bool:
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
Writes to a temp file then ``os.replace()``s onto the final name so a
symlink raced in at the destination is overwritten (rename semantics), not
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
replace), closing the parent-directory TOCTOU; otherwise falls back to
path-based temp+replace. Returns True on success. Never raises.
"""
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
# can't permanently block later seeds via an EEXIST collision.
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
try:
src_stat = source.stat()
except OSError as exc:
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
return False
if dir_fd is not None:
tmp_fd = None
try:
tmp_fd = os.open(
tmp_name,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
0o644,
dir_fd=dir_fd,
)
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
tmp_fd = None # fdopen now owns the descriptor
shutil.copyfileobj(sf, tf)
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
# refresh check matches the shutil.copy2 fallback path. Best-effort.
try:
os.utime(
dest_name,
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
dir_fd=dir_fd,
follow_symlinks=False,
)
except OSError as exc:
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
return True
except OSError as exc:
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
if tmp_fd is not None:
try:
os.close(tmp_fd)
except OSError:
pass
try:
os.unlink(tmp_name, dir_fd=dir_fd)
except OSError:
pass
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
os.close(fd)
shutil.copy2(source, tmp)
os.replace(tmp, dest_dir / dest_name)
tmp = None
return True
except OSError as exc:
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def seed_builtin_diagnostic_sloppaks(server_root: Path, dlc: Path | None = None) -> None:
"""Copy bundled diagnostic sloppaks into DLC before library scan.
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
when the destination is missing or older than the repo/bundle source.
Never deletes user files or touches manually copied paths (e.g.
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
diagnostic target is always available. Logs and continues on errors.
"""
try:
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
return
_copy_builtin_packs(
server_root,
dlc / BUILTIN_DIAGNOSTIC_SUBDIR,
BUILTIN_DIAGNOSTIC_SOURCES,
"Builtin diagnostic seed",
)
except Exception:
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
# seeded packs surface as ordinary library songs.
BUILTIN_STARTER_SUBDIR = "starter"
BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
(
"beethoven-fur_elise.feedpak",
"content/starter/beethoven-fur_elise.feedpak",
),
(
"star_spangled_banner.feedpak",
"content/starter/star_spangled_banner.feedpak",
),
(
"the_adicts-ode-to-joy_vst_cover.feedpak",
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
),
]
STARTER_SEED_MARKER = ".starter-content-seeded"
def seed_builtin_starter_content(server_root: Path, dlc: Path | None = None) -> None:
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
folder configured seeds the packs and writes the marker; subsequent runs are
no-ops, so a user who deletes the starter song does not get it back on the
next launch. Symlink-safe; never deletes user files. Logs, never raises.
"""
try:
marker = appstate.config_dir / STARTER_SEED_MARKER
# Already seeded? The marker is a sentinel: any existing path there
# (regular file, or a symlink/dir a user deliberately planted to opt
# out) means "done" — lstat so we detect it without following a symlink.
# Worst case of a planted marker is simply no starter content, never a
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
# *through* a symlink regardless.
try:
os.lstat(marker)
return
except FileNotFoundError:
pass
except OSError as exc:
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
return
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
# No DLC yet — leave the marker unwritten so we retry once a
# library folder is configured.
log.debug("Starter content seed: no DLC folder configured, skipping")
return
present = _copy_builtin_packs(
server_root,
dlc / BUILTIN_STARTER_SUBDIR,
BUILTIN_STARTER_SOURCES,
"Starter content seed",
update_existing=False,
)
# Only mark seeding complete once every starter pack is actually in
# place. If a source was missing or a copy failed, leave the marker
# unwritten so the next launch retries rather than permanently skipping.
if present < len(BUILTIN_STARTER_SOURCES):
log.info(
"Starter content seed: %d/%d packs present, will retry next launch",
present,
len(BUILTIN_STARTER_SOURCES),
)
return
# Record completion with an exclusive, no-follow create so a planted or
# raced symlink at the marker path can't redirect the write outside
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
# symlink, so we never write through one.
try:
appstate.config_dir.mkdir(parents=True, exist_ok=True)
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(marker, flags, 0o644)
try:
os.write(fd, b"1\n")
finally:
os.close(fd)
except FileExistsError:
pass # already marked (or a non-regular path is squatting) — fine
except OSError as exc:
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
except Exception:
log.warning("Starter content seed: unexpected error", exc_info=True)
+48
View File
@@ -6,6 +6,7 @@ import json
import logging
import mimetypes
import os
import re
import subprocess
import sys
import threading
@@ -2384,6 +2385,53 @@ def register_plugin_api(app: FastAPI):
return _plugin_file_response(request, script_file, "application/javascript")
return Response("", status_code=404)
# ── Module-graph cache busting (#879) ────────────────────────────────
#
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
# <script type="module"> whose src the module map has already seen fires
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
# and (see below) an upgrade too — silently kept the OLD module live while
# the loader recorded success: a no-op that reported it worked.
#
# Busting the ENTRY url does not help. A module plugin's screen.js is a
# one-line `import './src/main.js'`, and a relative specifier resolves
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
# the graph. Driving a real browser through install -> upgrade -> rollback and
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
# same URL; the module map returns the already-evaluated old module.
#
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
# relative import inherits it at every depth — for free, with no
# import-specifier rewriting (which could never see `import(expr)` anyway).
#
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
# URL, so EVERYTHING a module resolves relatively moves with it — not just
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
# worklet and wasm file the graph reaches — and would silently break again the
# next time someone adds a plugin route. Stripping the segment before routing
# makes every plugin route, present and future, work under the prefix.
#
# The token is opaque: it is never joined into a filesystem path (and is gone
# by the time any handler runs), so containment still rests entirely on the
# same safe_join the un-prefixed routes use.
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
@app.middleware("http")
async def _strip_plugin_generation_prefix(request: Request, call_next):
m = _GEN_PREFIX.match(request.scope.get("path", ""))
if m:
# Starlette routes on scope["path"] alone. raw_path is deliberately left
# ALONE: it is informational, and re-encoding the rewritten str back to
# bytes would have to guess a codec — `.encode("latin-1")` raises
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
# the client actually sent it is also simply more truthful for logs.
request.scope["path"] = m.group(1) + m.group(2)
return await call_next(request)
@app.get("/api/plugins/{plugin_id}/settings.html")
def plugin_settings_html(plugin_id: str):
with PLUGINS_LOCK:
+126
View File
@@ -2418,6 +2418,13 @@
let _venueSceneAssetsLoaded = false;
let _venueSceneLoadFailed = false;
const _venueTextureCache = new Map();
// Crowd video layers (career mode). venue-crowd.js owns the <video>
// elements and the crossfade timing; the renderer only maps them onto
// two planes in front of the static plate. _venueCrowdRev bumps on any
// element (re)assignment so update() knows to rebind textures.
const _venueCrowdVideos = [null, null];
let _venueCrowdMix = 0;
let _venueCrowdRev = 0;
function _bgVenueMoodCoeffs(state) {
const s = String(state || 'idle').toLowerCase();
@@ -2909,6 +2916,20 @@
window.h3dVenueSceneSetMood = (state) => {
_venueMoodState = String(state || 'idle').toLowerCase();
};
// Crowd video layers (career mode) — see venue-crowd.js. Layer 0/1 are
// two coplanar backdrop planes; mix selects between them (0 → layer 0,
// 1 → layer 1) so the caller can crossfade loop videos.
window.h3dVenueBackdropSetVideo = (layer, videoEl) => {
const i = layer ? 1 : 0;
const el = videoEl || null;
if (_venueCrowdVideos[i] === el) return;
_venueCrowdVideos[i] = el;
_venueCrowdRev++;
};
window.h3dVenueBackdropSetMix = (mix) => {
const v = Number(mix);
_venueCrowdMix = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
};
window.h3dVenueSceneSetInstrumentPov = (input) => {
const next = _venueResolvePovFromInput(input);
if (_venueInstrumentPov === next) return;
@@ -3371,6 +3392,40 @@
() => _venueMarkFailed('failed to load small-club bg plate'),
);
// Crowd video planes (career mode): two crossfading layers
// just in front of the static plate (which stays mounted as
// the no-pack / load-failure fallback). Textures bind lazily
// in update() when venue-crowd.js assigns video elements.
state.crowd = { layers: [], rev: -1 };
for (let i = 0; i < 2; i++) {
const geo = new T.PlaneGeometry(1, 1);
const mat = new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, opacity: 0,
depthWrite: false, fog: false,
});
const mesh = new T.Mesh(geo, mat);
mesh.visible = false;
// Layer 1 sits nearest so three.js's back-to-front
// transparent sort draws it after layer 0.
const layer = {
mesh, geo, mat, tex: null, videoEl: null,
cam: settings.cam,
distance: BG_BACKDROP_DISTANCE * (i === 0 ? 1.04 : 1.03),
lastAspect: 0, lastVisibleHeight: 0,
};
layer.applyCoverCrop = function () {
if (!layer.videoEl || !layer.tex) return;
_bgCoverCrop(
layer.tex,
layer.videoEl.videoWidth || 0,
layer.videoEl.videoHeight || 0,
layer.cam.aspect,
);
};
scene.add(mesh);
state.crowd.layers.push(layer);
}
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
const hazeMat = new T.MeshBasicMaterial({
color: 0x101820, transparent: true, opacity: coeffs.haze,
@@ -3402,6 +3457,64 @@
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
* (coeffs.haze / VENUE_HAZE_STEADY);
}
if (s.crowd) {
// Rebind VideoTextures when venue-crowd.js (re)assigns
// elements. VideoTexture samples the element every frame,
// so a src change on the same element needs no rebind.
if (s.crowd.rev !== _venueCrowdRev) {
s.crowd.rev = _venueCrowdRev;
s.crowd.layers.forEach((layer, i) => {
const el = _venueCrowdVideos[i];
if (layer.videoEl === el) return;
if (layer.tex) { layer.mat.map = null; layer.tex.dispose(); layer.tex = null; }
layer.videoEl = el;
layer.lastAspect = 0; // force refit + recrop
if (el) {
const tex = new T.VideoTexture(el);
tex.colorSpace = T.SRGBColorSpace;
tex.wrapS = T.ClampToEdgeWrapping;
tex.wrapT = T.ClampToEdgeWrapping;
tex.minFilter = T.LinearFilter;
tex.magFilter = T.LinearFilter;
tex.generateMipmaps = false;
layer.tex = tex;
layer.mat.map = tex;
}
layer.mat.needsUpdate = true;
});
}
const warm = coeffs.warmth;
s.crowd.layers.forEach((layer, i) => {
const el = layer.videoEl;
// videoWidth === 0 until metadata lands — showing the
// plane before that paints a black flash over the plate.
const ready = !!el && el.videoWidth > 0;
// venue-crowd.js swaps src on the same element (loop ↔
// stinger); a new intrinsic size needs a fresh
// cover-crop, which _bgFitBackdropPlane only reapplies
// on camera aspect changes.
if (ready && (layer.lastVidW !== el.videoWidth ||
layer.lastVidH !== el.videoHeight)) {
layer.lastVidW = el.videoWidth;
layer.lastVidH = el.videoHeight;
layer.applyCoverCrop();
}
// Layer 0 (rear) stays fully opaque whenever any of the
// fade involves it: two half-transparent layers would
// let the static plate behind bleed through (~25% at
// mid-fade). The crossfade is therefore layer 1 (front)
// fading over an opaque layer 0 — in both directions.
const opacity = i === 0
? (_venueCrowdMix < 0.999 ? 1 : 0)
: _venueCrowdMix;
layer.mat.opacity = opacity;
layer.mesh.visible = ready && opacity > 0.01;
if (layer.mesh.visible) {
layer.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
_bgFitBackdropPlane(layer);
}
});
}
},
teardown(s) {
if (!s) return;
@@ -3416,6 +3529,19 @@
p.mat.dispose?.();
}
}
// Crowd planes: this style owns the VideoTextures; the
// <video> elements belong to venue-crowd.js and survive.
if (s.crowd) {
for (const layer of s.crowd.layers) {
layer.mesh?.parent?.remove(layer.mesh);
layer.geo?.dispose?.();
if (layer.mat) {
layer.mat.map = null;
layer.mat.dispose?.();
}
layer.tex?.dispose?.();
}
}
// Dispose the cached plate textures too — the module-level cache
// otherwise keeps every loaded POV plate GPU-resident for the
// page lifetime (steady VRAM growth across POV/arrangement swaps).
+4 -324
View File
@@ -44,6 +44,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path
# `appstate.configure(...)` below publishes into the same namespace routers read.
# Lives in lib/ because that is the one core dir every packaging path copies.
import appstate
import builtin_content
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
from routers import tunings as tunings_router
@@ -645,13 +646,6 @@ def _make_scan_executor():
)
_BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
_BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
(
"feedBack-diagnostic-basic-guitar.sloppak",
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
),
]
def _feedBack_server_root() -> Path:
@@ -659,10 +653,6 @@ def _feedBack_server_root() -> Path:
return Path(__file__).resolve().parent
def _builtin_diagnostic_filename() -> str:
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
the onboarding challenge target (spec 010)."""
return f"{_BUILTIN_DIAGNOSTIC_SUBDIR}/{_BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
# Progression content (spec 010): bundled JSON under data/progression/ (paths,
@@ -694,329 +684,19 @@ def _get_progression_content() -> dict:
# path is unchanged; routers call `appstate.get_progression_content()`.
appstate.configure(
get_progression_content=_get_progression_content,
builtin_diagnostic_filename=_builtin_diagnostic_filename,
builtin_diagnostic_filename=builtin_content.builtin_diagnostic_filename,
tuning_providers=tuning_providers,
)
def _copy_builtin_packs(
root: Path,
dest_dir: Path,
sources: list[tuple[str, str]],
label: str,
update_existing: bool = True,
) -> int:
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
bundled). A pack is copied when its destination is missing. Never deletes
user files; refuses to follow a symlinked seed directory or destination and
refuses to clobber a non-regular destination (any would let a copy escape
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
prefixes every log line.
``update_existing`` controls what happens when a *regular* destination file
already exists: when True (diagnostic seed) a bundle copy newer than the
destination refreshes it; when False (one-time starter content) an existing
file is always left as-is so the user's copy is never overwritten.
Returns the number of ``sources`` that are present at their destination
afterwards (freshly seeded, refreshed, or already current) — so callers can
tell whether every pack made it. A skip (missing source, symlink/non-regular
refusal, copy error) does not count.
"""
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
# and copies would land at the link target, outside the DLC tree. The
# per-file symlink guard below cannot catch this.
if dest_dir.is_symlink():
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
return 0
dest_dir.mkdir(parents=True, exist_ok=True)
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
# dest_dir *after* the check above cannot redirect the per-file stat /
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
# os.replace accepts dir_fd on POSIX even though it isn't listed in
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
dir_fd = None
if (
hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
and os.open in os.supports_dir_fd
and os.rename in os.supports_dir_fd
):
try:
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
except OSError as exc:
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
return 0
try:
present = 0
for dest_name, rel_source in sources:
source = root / rel_source
if not source.is_file():
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
continue
# lstat the destination without following symlinks. Pinned by dir_fd
# this resolves within the real seed dir, immune to a parent swap.
try:
if dir_fd is not None:
dstat = os.lstat(dest_name, dir_fd=dir_fd)
else:
dstat = os.lstat(dest_dir / dest_name)
dest_exists = True
dest_islink = stat.S_ISLNK(dstat.st_mode)
except FileNotFoundError:
dest_exists = False
dest_islink = False
except OSError as exc:
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
continue
# Refuse to seed through a symlink at the destination name.
if dest_islink:
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
continue
# A non-regular destination (directory, fifo, …) the user placed
# there: never clobber it, and never count it as present — otherwise
# a one-time seed would mark itself done without a real pack on disk.
if dest_exists and not stat.S_ISREG(dstat.st_mode):
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
continue
if dest_exists:
# A regular file is already there. One-time seeds (starter
# content) must never overwrite the user's copy; refreshing
# seeds (diagnostics) replace it only when the bundle is newer.
if not update_existing:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
try:
src_mtime = source.stat().st_mtime
except OSError as exc:
log.warning("%s: cannot stat source %s: %s", label, source, exc)
continue
if src_mtime <= dstat.st_mtime:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
action = "updated"
else:
action = "seeded"
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
present += 1
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
else:
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
return present
finally:
if dir_fd is not None:
os.close(dir_fd)
def _write_builtin_pack(
source: Path,
dest_dir: Path,
dest_name: str,
dir_fd: int | None,
) -> bool:
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
Writes to a temp file then ``os.replace()``s onto the final name so a
symlink raced in at the destination is overwritten (rename semantics), not
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
replace), closing the parent-directory TOCTOU; otherwise falls back to
path-based temp+replace. Returns True on success. Never raises.
"""
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
# can't permanently block later seeds via an EEXIST collision.
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
try:
src_stat = source.stat()
except OSError as exc:
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
return False
if dir_fd is not None:
tmp_fd = None
try:
tmp_fd = os.open(
tmp_name,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
0o644,
dir_fd=dir_fd,
)
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
tmp_fd = None # fdopen now owns the descriptor
shutil.copyfileobj(sf, tf)
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
# refresh check matches the shutil.copy2 fallback path. Best-effort.
try:
os.utime(
dest_name,
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
dir_fd=dir_fd,
follow_symlinks=False,
)
except OSError as exc:
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
return True
except OSError as exc:
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
if tmp_fd is not None:
try:
os.close(tmp_fd)
except OSError:
pass
try:
os.unlink(tmp_name, dir_fd=dir_fd)
except OSError:
pass
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
os.close(fd)
shutil.copy2(source, tmp)
os.replace(tmp, dest_dir / dest_name)
tmp = None
return True
except OSError as exc:
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
"""Copy bundled diagnostic sloppaks into DLC before library scan.
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
when the destination is missing or older than the repo/bundle source.
Never deletes user files or touches manually copied paths (e.g.
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
diagnostic target is always available. Logs and continues on errors.
"""
try:
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
return
_copy_builtin_packs(
_feedBack_server_root(),
dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
_BUILTIN_DIAGNOSTIC_SOURCES,
"Builtin diagnostic seed",
)
except Exception:
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
# seeded packs surface as ordinary library songs.
_BUILTIN_STARTER_SUBDIR = "starter"
_BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
(
"beethoven-fur_elise.feedpak",
"content/starter/beethoven-fur_elise.feedpak",
),
(
"star_spangled_banner.feedpak",
"content/starter/star_spangled_banner.feedpak",
),
(
"the_adicts-ode-to-joy_vst_cover.feedpak",
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
),
]
_STARTER_SEED_MARKER = ".starter-content-seeded"
def _seed_builtin_starter_content(dlc: Path | None = None) -> None:
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
folder configured seeds the packs and writes the marker; subsequent runs are
no-ops, so a user who deletes the starter song does not get it back on the
next launch. Symlink-safe; never deletes user files. Logs, never raises.
"""
try:
marker = CONFIG_DIR / _STARTER_SEED_MARKER
# Already seeded? The marker is a sentinel: any existing path there
# (regular file, or a symlink/dir a user deliberately planted to opt
# out) means "done" — lstat so we detect it without following a symlink.
# Worst case of a planted marker is simply no starter content, never a
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
# *through* a symlink regardless.
try:
os.lstat(marker)
return
except FileNotFoundError:
pass
except OSError as exc:
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
return
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
# No DLC yet — leave the marker unwritten so we retry once a
# library folder is configured.
log.debug("Starter content seed: no DLC folder configured, skipping")
return
present = _copy_builtin_packs(
_feedBack_server_root(),
dlc / _BUILTIN_STARTER_SUBDIR,
_BUILTIN_STARTER_SOURCES,
"Starter content seed",
update_existing=False,
)
# Only mark seeding complete once every starter pack is actually in
# place. If a source was missing or a copy failed, leave the marker
# unwritten so the next launch retries rather than permanently skipping.
if present < len(_BUILTIN_STARTER_SOURCES):
log.info(
"Starter content seed: %d/%d packs present, will retry next launch",
present,
len(_BUILTIN_STARTER_SOURCES),
)
return
# Record completion with an exclusive, no-follow create so a planted or
# raced symlink at the marker path can't redirect the write outside
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
# symlink, so we never write through one.
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(marker, flags, 0o644)
try:
os.write(fd, b"1\n")
finally:
os.close(fd)
except FileExistsError:
pass # already marked (or a non-regular path is squatting) — fine
except OSError as exc:
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
except Exception:
log.warning("Starter content seed: unexpected error", exc_info=True)
def _background_scan():
@@ -1038,8 +718,8 @@ def _background_scan():
log.warning("Scan: no DLC folder configured")
return
_seed_builtin_diagnostic_sloppaks(dlc)
_seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(_feedBack_server_root(), dlc)
builtin_content.seed_builtin_starter_content(_feedBack_server_root(), dlc)
# 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
+282 -6245
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// The one <audio> element the whole app plays through.
//
// This exists so that code carved out of app.js can reach the player without
// importing app.js back — which would close a cycle and fail the import-x/no-cycle
// gate. It is the same handle app.js has always held (`document.getElementById`
// on the element in the shell), just given a home of its own.
//
// It is deliberately a `const`, and it is never reassigned anywhere in core — so a
// read-only import binding is exactly right, and no state container is needed.
// (Contrast the reassigned scalars — isPlaying, _avOffsetMs, … — which cannot be
// shared this way, because an imported binding cannot be written to.)
//
// Module scripts evaluate after the HTML is parsed, so the element is already in
// the document by the time this runs. app.js is loaded as <script type="module">,
// and its imports evaluate before its body — the same point at which app.js used
// to run this exact lookup itself.
export const audio = document.getElementById('audio');
+389
View File
@@ -0,0 +1,389 @@
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
// shares its lifecycle and timers.
//
// The third slice out of app.js's strongly-connected core, and the first that had to
// WRITE shared state rather than just read it. It starts and stops playback, so it sets
// `isPlaying` and `lastAudioTime`. An imported binding is read-only — `isPlaying = true`
// throws — which is exactly why those two scalars were lifted onto the container in
// ./player-state.js. Every earlier slice only READ what it shared, so a getter hook
// sufficed; this one could not.
//
// It imports the loop module directly (setLoop / loopA / loopB — a count-in that starts
// inside an A-B loop must begin at A). Nothing imports count-in back: app.js and
// section-practice both reach it through the host seam, so the graph stays acyclic.
//
// app.js's autoplay path used to reach IN and set the credits timers itself. It cannot
// now, and it should not have to — so the module exports the OPERATIONS instead
// (armCreditsHideOnPlay, scheduleCreditsHide, holdCreditsThen, isCountingIn) and owns
// its own timer invariants. Same reason section-practice grew resetSelection().
//
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
import { audio } from './audio-el.js';
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js';
import { loopA, loopB, setLoop } from './loops.js';
import { S } from './player-state.js';
// ── Count-in click sound (Web Audio API) ────────────────────────────────
let _audioCtx = null;
export function playClick(high = false) {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = _audioCtx.createOscillator();
const gain = _audioCtx.createGain();
osc.connect(gain);
gain.connect(_audioCtx.destination);
osc.frequency.value = high ? 1200 : 800;
osc.type = 'sine';
gain.gain.setValueAtTime(0.5, _audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.08);
osc.start(_audioCtx.currentTime);
osc.stop(_audioCtx.currentTime + 0.08);
}
let _countingIn = false;
let _countOverlay = null;
// Generation token so teardown can cancel an in-progress count-in. Each
// startCountIn() captures the gen at entry; rewindStep, the loop-wrap
// then-callback, and beginCount's tick all bail when their captured gen
// no longer matches. Bumped by _cancelCountIn().
let _countInGen = 0;
let _countInTimer = null;
let _countInRaf = 0;
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
// highway when a song is loaded, alongside the count-in. Torn down together
// with the count-in via _cancelCountIn().
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_HOLD_MS = 3000;
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
// a count-in handoff that never plays). This hard cap guarantees the credits
// never linger over the highway. Generous enough to outlast a normal count-in.
const _CREDITS_MAX_MS = 12000;
export function _cancelCountIn() {
_countInGen++;
_countingIn = false;
hideCountOverlay();
// The credits overlay rides the count-in lifecycle (and its no-count-in
// hold timer), so a teardown — leaving the player, loading another song —
// must clear it too, or it lingers on the next screen.
hideSongCreditsOverlay();
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
}
export function showCountOverlay(n) {
if (!_countOverlay) {
_countOverlay = document.createElement('div');
_countOverlay.className = 'fixed inset-0 z-[100] flex items-center justify-center pointer-events-none';
document.body.appendChild(_countOverlay);
}
_countOverlay.innerHTML = `<span class="text-9xl font-black text-white/30">${n}</span>`;
}
export function hideCountOverlay() {
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
}
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
const _CREDIT_ROLE_VERBS = {
charter: 'Charted by',
transcriber: 'Transcribed by',
arranger: 'Arranged by',
editor: 'Edited by',
mixer: 'Mixed by',
engineer: 'Engineered by',
proofreader: 'Proofread by',
};
function _creditLineLabel(role) {
if (!role) return '';
const key = String(role).trim().toLowerCase();
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
}
// Show the feedpak contributor credits over the highway. `authors` is the
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
// Anchored to the lower third (bottom-center) so it never collides with the
// vertically-centered count-in number, and pointer-events-none so it never
// intercepts clicks. No-op when there are no contributors to show.
export function showSongCreditsOverlay(authors) {
if (!Array.isArray(authors) || authors.length === 0) return;
if (!_creditsOverlay) {
_creditsOverlay = document.createElement('div');
_creditsOverlay.className = 'song-credits-overlay';
document.body.appendChild(_creditsOverlay);
}
// Build via DOM + textContent — author names are untrusted pack data and
// must never be interpolated as HTML.
_creditsOverlay.replaceChildren();
const card = document.createElement('div');
card.className = 'song-credits-card';
const eyebrow = document.createElement('div');
eyebrow.className = 'song-credits-eyebrow';
eyebrow.textContent = 'Credits';
card.appendChild(eyebrow);
const title = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.title) || '';
if (title) {
const heading = document.createElement('div');
heading.className = 'song-credits-heading';
heading.textContent = title;
card.appendChild(heading);
}
for (const a of authors) {
if (!a || !a.name) continue;
const row = document.createElement('div');
row.className = 'song-credits-line';
const label = _creditLineLabel(a.role);
if (label) {
const lab = document.createElement('span');
lab.className = 'song-credits-role';
lab.textContent = label + ' ';
row.appendChild(lab);
}
const nm = document.createElement('span');
nm.className = 'song-credits-name';
nm.textContent = a.name;
row.appendChild(nm);
card.appendChild(row);
}
_creditsOverlay.appendChild(card);
// Arm the backstop so the overlay self-clears even if playback never starts
// / never emits song:play. song:play (or any teardown) clears it earlier.
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
}
export function hideSongCreditsOverlay() {
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
if (_creditsHideOnPlay) {
window.feedBack.off('song:play', _creditsHideOnPlay);
_creditsHideOnPlay = null;
}
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
}
export async function startCountIn(opts = {}) {
if (_countingIn) return;
_countingIn = true;
// Snapshot the current gen so every delayed callback (rewind frames,
// post-seek then, count-in ticks, post-count play) can bail if a
// teardown bumped the gen mid-flight via _cancelCountIn().
const gen = _countInGen;
const immediate = !!opts.immediate;
if (window._juceMode) {
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err));
} else {
audio.pause();
}
if (gen !== _countInGen) return; // teardown during pause
// Section-practice entry: already at loop A after setLoop(); skip the
// B→A rewind animation used on loop wrap and go straight to clicks.
if (immediate) {
if (loopA === null || loopB === null) {
_countingIn = false;
return;
}
S.lastAudioTime = loopA;
highway.setTime(loopA);
if (window.feedBack) {
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
}
beginCount();
return;
}
// Rewind animation: sweep highway time from B to A
const rewindDuration = 400; // ms
const rewindStart = performance.now();
const fromTime = loopB;
const toTime = loopA;
function rewindStep(now) {
if (gen !== _countInGen) return; // teardown mid-rewind
const elapsed = now - rewindStart;
const t = Math.min(elapsed / rewindDuration, 1);
// Ease out quad
const eased = 1 - (1 - t) * (1 - t);
const currentT = fromTime + (toTime - fromTime) * eased;
highway.setTime(currentT);
if (t < 1) {
_countInRaf = requestAnimationFrame(rewindStep);
} else {
_countInRaf = 0;
// Rewind done — set final position and start count.
// Await the JUCE seek so the engine has repositioned before
// we start the click track (HTML5 path is synchronous).
_audioSeek(loopA, 'loop-wrap').then((r) => {
if (gen !== _countInGen) return; // teardown during seek
// Abort the loop restart in two cases:
// 1. Cancelled (player torn down): don't beginCount on a
// new session.
// 2. Off-target landing (JUCE rollback / clamp far from
// loopA): proceeding would emit loop:restart and start
// a count-in from the wrong position. Audio is at
// r.from / r.to, which is not where the loop wants to
// resume — better to drop this iteration than play out
// of sync.
// 50 ms tolerance: well within JUCE's normal seek precision
// but tight enough to catch a real rollback or no-op.
if (!r.completed || Math.abs(r.to - loopA) > 0.05) {
// startCountIn paused audio at entry but left isPlaying
// alone — beginCount would have set it on resume. On
// abort, sync the transport: audio is paused, so
// isPlaying must reflect that and the button + plugin
// host must agree.
_countingIn = false;
if (S.isPlaying) {
S.isPlaying = false;
setPlayButtonState(false);
if (window.feedBack) {
window.feedBack.isPlaying = false;
window.feedBack.emit('song:pause', _songEventPayload());
}
}
return;
}
// Use the verified post-seek clock for the chart so audio
// and chart stay in sync if JUCE clamped to slightly
// before/after loopA. The loop:restart event keeps `time:
// loopA` because subscribers treat that as the semantic
// marker for "new iteration starts at A", not the actual
// audio position.
S.lastAudioTime = r.to;
highway.setTime(r.to);
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
beginCount();
});
}
}
_countInRaf = requestAnimationFrame(rewindStep);
function beginCount() {
const bpm = highway.getBPM(loopA);
const beatInterval = 60 / bpm;
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
hideCountOverlay();
_countingIn = false;
if (window._juceMode) {
jucePlayer.play().then((started) => {
if (gen !== _countInGen) return; // teardown during play start
if (!started) return;
S.isPlaying = true;
setPlayButtonState(true);
window.feedBack.isPlaying = true;
const payload = _songEventPayload();
window.feedBack.emit('song:play', payload);
window.feedBack.emit('song:resume', payload);
}).catch((err) => console.error('[app] jucePlayer.play error:', err));
} else {
audio.play().then(() => {
if (gen !== _countInGen) return;
S.isPlaying = true;
setPlayButtonState(true);
}).catch((err) => {
if (gen !== _countInGen) return;
// An engine reroute's deliberate pause aborts this play()
// while playback continues on JUCE — don't reset the
// button (mirrors the togglePlay guard).
if (window._juceRerouteInProgress) return;
// Same rationale as togglePlay: don't claim playback
// started if the Promise rejected.
console.error('[app] audio.play() rejected after count-in:', err);
S.isPlaying = false;
setPlayButtonState(false);
});
}
return;
}
showCountOverlay(count);
playClick(count === 1);
_countInTimer = setTimeout(tick, beatInterval * 1000);
}
_countInTimer = setTimeout(tick, 500);
}
}
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
// overlay + click + gen-token cancellation, but counts from the song's current
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
// coupled (early-returns when loopA/loopB are null), so this is a sibling
// rather than an overload. Hands off to togglePlay() once the count completes.
export async function startSongCountIn() {
if (_countingIn) return;
_countingIn = true;
// Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn)
// bumps it and every delayed callback below bails.
const gen = _countInGen;
if (window._juceMode) {
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
} else {
audio.pause();
}
if (gen !== _countInGen) return; // teardown during pause
const startT = S.lastAudioTime || 0;
let bpm = highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
const beatInterval = 60 / bpm;
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
hideCountOverlay();
_countingIn = false;
// Hand off to the normal play path — togglePlay() flips isPlaying,
// updates the button, and emits song:play/resume for plugins.
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
return;
}
showCountOverlay(count);
playClick(count === 1);
_countInTimer = setTimeout(tick, beatInterval * 1000);
}
// First beat after a short lead-in, matching the loop count-in's 500 ms.
_countInTimer = setTimeout(tick, 500);
}
// ── Operations app.js's autoplay path used to perform by reaching in ────────
// It used to assign _creditsTimer / _creditsHideOnPlay directly. Imported bindings are
// read-only, and the module should own its own timer invariants anyway.
/** Is a count-in running? app.js's timeupdate handler suppresses highway sync during one. */
export function isCountingIn() {
return _countingIn;
}
/** Dismiss the credits the moment real playback begins. Fires once. */
export function armCreditsHideOnPlay() {
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
}
/** Let the credits dwell, then clear them. Used when autoplay-exit is disabled. */
export function scheduleCreditsHide() {
_creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
}
/** Let the credits dwell, then run `then` (the autoplay start). */
export function holdCreditsThen(then) {
_creditsTimer = setTimeout(() => { _creditsTimer = null; then(); }, _CREDITS_HOLD_MS);
}
+17
View File
@@ -0,0 +1,17 @@
// Display formatters. A LEAF module: imports nothing.
//
// WHY THIS EXISTS FOR ONE FUNCTION. formatTime was a HOST HOOK — loops.js and
// section-practice.js both reached back through the seam for it. It was also, by pure
// accident of who calls it, inside the dependency closure of the library carve. Leaving
// it there would have made loops.js and section-practice.js import the LIBRARY to format
// a timestamp, which is nonsense, and a cycle waiting to happen.
//
// A hook is a cycle you agreed to live with. This one has a real owner — it just isn't
// app.js, and it certainly isn't the library. Give it a home of its own and both
// consumers import it directly.
//
// It is a leaf on purpose. Anything else that turns out to be a shared pure formatter
// belongs here too; nothing does yet, so nothing else is here.
/** Seconds -> `M:SS`. */
export function formatTime(s) { return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`; }
+601
View File
@@ -0,0 +1,601 @@
// Highway string colours — user theming for the 2D + bundled 3D highways.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Slot→hex colours (per named string slot, so a 6-string map survives a 4-string
// bass and a 7-string's Low B), named themes in localStorage, a copy/paste share
// code, and the Settings-screen picker UI. The highways colour by raw string
// INDEX, so a translation table maps named slots → per-index colours for the
// current arrangement, recomputed whenever a song loads.
//
// Exports exactly two entry points; the other 43 symbols (the HWC_* tables, the
// theme store, the picker handlers, the window.feedBack facade) are used nowhere
// else in core and stay private. The Settings buttons are wired by
// addEventListener inside hwcInitSettingsUI — there are no inline on*= handlers
// here, so nothing needs re-exposing on window.
//
// It does import uiPrompt from ./dom.js (the "name this theme" prompt) — which is
// precisely why dom.js was carved out first: without it this module would have
// needed a host seam back into app.js.
import { uiPrompt } from './dom.js';
// Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the
// extended low strings of 7/8-string guitars), so a string keeps its color
// when the string count changes (e.g. Low E stays the same from a 6-string
// guitar to a 4-string bass, and on a 7-string the extra Low B takes the
// 7-string slot rather than bumping every color over). The highways color by
// raw string INDEX, so a small translation table maps named slots → per-index
// colors for the current arrangement; this is recomputed whenever a song loads
// (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and
// bundled 3D highway; stored client-side; shared via a copy/paste code.
const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active)
const HWC_KEY_THEMES = 'highwayColorThemes'; // { "<name>": {slot:hex} }
const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or ''
const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;
// Named color slots, in display order (high → low, then extended low strings).
const HWC_SLOTS = [
{ key: 'highE', label: 'High E', sub: '1st' },
{ key: 'B', label: 'B', sub: '2nd' },
{ key: 'G', label: 'G', sub: '3rd' },
{ key: 'D', label: 'D', sub: '4th' },
{ key: 'A', label: 'A', sub: '5th' },
{ key: 'lowE', label: 'Low E', sub: '6th / lowest' },
{ key: 'low7', label: 'Low B', sub: '7-string' },
{ key: 'low8', label: 'Low F#', sub: '8-string' },
];
const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
// Hardcoded fallback (matches the highway defaults) for before the 2D highway
// is queryable.
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
// One-click string-color presets. Each is a full named-slot → hex map (every
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
// string count. Hues are chosen for the dark scene (~#080810): each color is
// bright enough to read on black and distinct from its neighbours.
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
// cool blues/violet at the treble end) so pitch reads as color temperature.
// - vivid: punchier, higher-saturation take on the classic mapping for a
// stage-bright look.
// - colorblind: the OkabeIto accessible qualitative palette (vermillion,
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
// distinguishable option for deuteranopia/protanopia.
// - colorblind_deuteranope: a deuteranope-tuned variant of the OkabeIto set
// above, contributed by a deuteranopic player who still found that set hard
// to separate. Retunes the six main strings (red / yellow-green / blue /
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
// violet) so adjacent strings separate harder than vivid — a stage/stream
// "pop" set, not a vivid duplicate.
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
// OkabeIto 'colorblind' set, the value ramp teaches pitch low→high AND
// survives grayscale/colorblindness; no red/green pair carries meaning.
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
// from rust/ember at the bass through warm gold to cream at the treble. The
// bass embers stay light enough to clear the near-black scene.
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
// so lightness alone carries the ordering. Verified to stay legible even on
// the matching phosphor scene board (green-on-green / amber-on-amber).
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
// yellow → warm-white) with rising lightness — memorable + teaches order.
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
// warm and lower-intensity, lightness-stepped low→high.
const HWC_PRESETS = [
{
id: 'warmcool', label: 'Warm → Cool',
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
},
{
id: 'vivid', label: 'Vivid',
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
},
{
id: 'colorblind', label: 'Colorblind-friendly',
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
},
{
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
},
{
id: 'neon', label: 'Neon',
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
},
{
id: 'accessible', label: 'Accessible (ordered)',
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
},
{
id: 'ember', label: 'Warm Ember',
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
},
{
id: 'tapedeck', label: 'Tape Deck',
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
},
{
id: 'crtgreen', label: 'CRT Green',
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
},
{
id: 'crtamber', label: 'CRT Amber',
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
},
{
id: 'pitchramp', label: 'Pitch Ramp',
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
},
{
id: 'sunrise', label: 'Sunrise',
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
},
];
// Translation table: chart string index → named slot, for a given string count
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
// and sub-6 guitars truncate from the high end. Index 0 is always the lowest.
function _hwcSlotKeysForChart(sc, isBass) {
sc = Math.max(1, Math.min(8, (sc | 0) || 6));
if (isBass) {
if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc);
if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G'];
return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc);
}
if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc);
if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
}
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
function _hwcChartShape() {
let sc = 6, arr = '';
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {}
return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) };
}
// Normalize an arbitrary value to a slot→hex map of validated lowercase colors
// (absent / invalid slots are omitted).
function _hwcNormalize(slotMap) {
const out = {};
if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) {
for (const k of HWC_SLOT_KEYS) {
const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : '';
if (HWC_HEX_RE.test(v)) out[k] = v;
}
}
return out;
}
// Canonical default color per named slot (the classic highway mapping).
// Fixed, not read back from the highway (which may already be name-remapped for
// a 7/8-string chart), so the pickers always preview the true per-name default.
function getHighwayDefaultSlotColors() {
return { ...HWC_DEFAULT_FALLBACK };
}
// Active (user-customized) slot→hex map from storage ({} when none set).
function getHighwayStringColors() {
try {
const raw = localStorage.getItem(HWC_KEY_ACTIVE);
if (raw) return _hwcNormalize(JSON.parse(raw));
} catch (_) { /* corrupt / blocked */ }
return {};
}
// Defaults overlaid with the user's custom slots (custom wins). Always a full
// 8-slot map, so name-mapping has a color for every string of any arrangement.
function _hwcMergedSlotColors() {
return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() };
}
// True when the slot→index mapping is the identity (index 0 = lowest = Low E):
// guitar ≤6 strings and 4-string bass. For these the name mapping equals the
// stock index order, so we leave the highways on their hand-tuned defaults
// (byte-identical) unless the user set custom colors. Extended-range charts —
// 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#),
// shifting Low E up an index, so their defaults must be name-remapped too.
function _hwcMappingIsIdentity(sc, isBass) {
return isBass ? sc <= 4 : sc <= 6;
}
// Translate a full slot map into the index-keyed array the highways consume.
function _hwcEffectiveIndexColors(slotMap, sc, isBass) {
const keys = _hwcSlotKeysForChart(sc, isBass);
return keys.map((k) => slotMap[k] || null);
}
// Persist the user's custom slot map (or clear it), then apply. Only slots that
// actually DIFFER from the default are stored — so reverting every picker to its
// stock color persists as empty and the identity/stock path is restored (rather
// than pinning the highways on an all-default "custom" theme).
function applyHighwayStringColors(slotMap, opts) {
const persist = !opts || opts.persist !== false;
const colors = _hwcNormalize(slotMap);
const defaults = getHighwayDefaultSlotColors();
const overrides = {};
for (const k of Object.keys(colors)) {
if (colors[k] !== defaults[k]) overrides[k] = colors[k];
}
if (persist) {
try {
if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides));
else localStorage.removeItem(HWC_KEY_ACTIVE);
} catch (_) {}
}
reapplyHighwayStringColors();
}
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
// Persists + applies to both highways (via applyHighwayStringColors), then —
// when the Settings UI is mounted — refreshes the per-string pickers so their
// swatches show the preset's colors. Unknown id is a no-op.
function applyHighwayStringPreset(id) {
const preset = HWC_PRESETS.find((p) => p.id === id);
if (!preset) return false;
applyHighwayStringColors(preset.colors);
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
return true;
}
// Apply colors by NAMED string to both highways for the current arrangement.
// Colors follow the string name regardless of count: Low E stays Low E's color
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
// 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults
// too so Low E keeps its color. The String Colors UI replaces the 3D highway's
// old palette picker, so core always drives the 3D string colors here.
function reapplyHighwayStringColors() {
const { sc, isBass } = _hwcChartShape();
const custom = getHighwayStringColors();
const hasCustom = Object.keys(custom).length > 0;
if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) {
// Pure stock defaults in natural order — leave the hand-tuned highway
// defaults intact, and make sure the 3D is on its plain default palette
// (clears any stale 'custom' / leftover palette selection).
try { window.highway?.setStringColors?.(null); } catch (_) {}
try {
if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default');
} catch (_) {}
try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {}
return;
}
const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass);
try { window.highway?.setStringColors?.(eff); } catch (_) {}
try { window.h3dBgSetStringColors?.(eff); } catch (_) {}
try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {}
}
function _hwcReadThemes() {
// Null-prototype store: theme names come from user input / share codes, so
// names like `constructor`/`toString`/`__proto__` must not collide with
// inherited Object properties or mutate the prototype.
try {
const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null);
const out = Object.create(null);
for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors);
return out;
} catch (_) { return Object.create(null); }
}
function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} }
function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); }
function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } }
function saveHighwayColorTheme(name, slotMap) {
name = String(name || '').trim();
if (!name) return false;
const o = _hwcReadThemes();
o[name] = _hwcNormalize(slotMap);
_hwcWriteThemes(o);
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
return true;
}
function deleteHighwayColorTheme(name) {
const o = _hwcReadThemes();
if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); }
if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} }
}
// Select a saved theme by name, or pass '' to revert to defaults.
function selectHighwayColorTheme(name) {
if (!name) {
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
applyHighwayStringColors(null);
return;
}
const o = _hwcReadThemes();
if (!Object.prototype.hasOwnProperty.call(o, name)) return;
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
applyHighwayStringColors(o[name]);
}
// Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where
// c is the named slot→hex map.
function encodeHighwayColorShare(name, slotMap) {
const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) };
const json = JSON.stringify(payload);
let b64;
try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); }
return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function decodeHighwayColorShare(code) {
if (typeof code !== 'string') return null;
let s = code.trim();
// Require the exact versioned prefix. Anything else (a future/legacy
// SLOPHWY*, or unprefixed text) is rejected so the version boundary is real.
const PREFIX = 'SLOPHWY2.';
if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null;
s = s.slice(PREFIX.length);
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
let json;
try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } }
let obj;
try { obj = JSON.parse(json); } catch (_) { return null; }
if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null;
return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) };
}
// Import a share code: store it as a (uniquely named) saved theme and apply.
function importHighwayColorShare(code) {
const parsed = decodeHighwayColorShare(code);
if (!parsed) return null;
let name = parsed.name || 'Imported';
const existing = _hwcReadThemes();
if (Object.prototype.hasOwnProperty.call(existing, name)) {
let i = 2;
while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++;
name = name + ' ' + i;
}
saveHighwayColorTheme(name, parsed.colors);
applyHighwayStringColors(parsed.colors);
return { name, colors: parsed.colors };
}
// Startup: apply persisted colors to the 2D highway immediately and re-apply on
// every song load (string count / bass-vs-guitar can change the slot→index
// mapping) and whenever a viz renderer (re)initializes (the 3D loads async +
// rebuilds per song, so a one-shot apply could land before it exists).
let _hwcWired = false;
export function initHighwayColors() {
reapplyHighwayStringColors();
if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') {
_hwcWired = true;
window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors);
window.feedBack.on('song:loaded', reapplyHighwayStringColors);
window.feedBack.on('song:ready', reapplyHighwayStringColors);
}
_hwcInstallFacade();
}
// ── Public plugin API: window.feedBack.highwayColors ─────────────────────
// A stable, documented facade over the (otherwise private) string-color
// manager so plugins can read / react to / set the user's per-string colors
// without reaching into internals. This is a synchronous data-plane API, not a
// capability domain — consistent with the constitution keeping highway/viz
// surfaces off the capability graph until a dedicated render-facade slice
// lands. Colors are keyed by NAMED string slot (see `slots`); use
// `keysForChart`/`toEffective` to map names → per-string-index for a given
// arrangement. See docs/plugin-capability-inventory.md.
const _hwcChangeWrappers = new WeakMap();
function _hwcInstallFacade() {
if (!window.feedBack || window.feedBack.highwayColors) return;
const api = {
version: 1,
// Ordered named slots: [{ key, label, sub }]. `key` is the stable id.
slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })),
// User-set overrides only (named slot → hex); empty object = defaults.
get() { return getHighwayStringColors(); },
// Canonical default color per named slot.
getDefaults() { return getHighwayDefaultSlotColors(); },
// Defaults overlaid with overrides — the colors in effect, by name.
getResolved() { return _hwcMergedSlotColors(); },
// Which named slot each chart string index maps to, for an arrangement
// (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...].
keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); },
// Per-string-INDEX hex array (resolved colors) for an arrangement.
// Omit args to use the currently-loaded chart's shape.
toEffective(stringCount, isBass) {
const shape = (typeof stringCount === 'number')
? { sc: stringCount, isBass: !!isBass }
: _hwcChartShape();
return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass);
},
// The per-index colors actually applied to the live 2D highway now.
getCurrent() {
try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; }
catch (_) { return []; }
},
// Set colors programmatically (persists + applies to both highways).
// Pass a named slot map, or null/{} to revert to defaults.
apply(slotMap) { return applyHighwayStringColors(slotMap); },
// One-click presets: [{ id, label, colors }] (full named-slot maps).
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
// Apply a preset by id (persists + applies to both highways).
applyPreset(id) { return applyHighwayStringPreset(id); },
// Share-code interop (the "SLOPHWY2." copy/paste format).
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
decodeShare(code) { return decodeHighwayColorShare(code); },
// Subscribe to color changes; handler receives the resolved slot map.
// Returns an unsubscribe fn that removes exactly THIS subscription;
// offChange(fn) removes every subscription registered with that fn.
// (Each fn maps to a Set of wrappers so repeated mount/init paths that
// subscribe the same handler don't clobber each other or leak.)
onChange(fn) {
if (typeof fn !== 'function' || !window.feedBack) return () => {};
const wrapper = () => {
try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); }
};
let set = _hwcChangeWrappers.get(fn);
if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); }
set.add(wrapper);
window.feedBack.on('highway:stringColors', wrapper);
return () => {
if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper);
const s = _hwcChangeWrappers.get(fn);
if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); }
};
},
offChange(fn) {
const set = _hwcChangeWrappers.get(fn);
if (set && window.feedBack) {
for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper);
_hwcChangeWrappers.delete(fn);
}
},
};
window.feedBack.highwayColors = api;
}
// ── Highway String Colors — Settings UI wiring ───────────────────────────
// Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color
// keeps Low E that color regardless of string count — the translation table
// (_hwcSlotKeysForChart) handles the index remapping per arrangement.
function _hwcStatus(msg) {
const el = document.getElementById('hwc-status');
if (!el) return;
el.textContent = msg || '';
if (msg) {
clearTimeout(_hwcStatus._t);
_hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500);
}
}
// Render one color input per named slot, seeded from active colors (falling
// back to the highway defaults for that slot).
function hwcRenderPickers() {
const host = document.getElementById('hwc-pickers');
if (!host) return;
const defaults = getHighwayDefaultSlotColors();
const active = getHighwayStringColors();
host.innerHTML = '';
for (const slot of HWC_SLOTS) {
const val = active[slot.key] || defaults[slot.key] || '#888888';
const wrap = document.createElement('label');
wrap.className = 'flex items-center gap-2 text-xs text-gray-400';
const input = document.createElement('input');
input.type = 'color';
input.id = 'hwc-color-' + slot.key;
input.dataset.slot = slot.key;
input.value = val;
input.style.width = '2.5rem';
input.style.height = '1.75rem';
input.style.padding = '2px';
input.style.cursor = 'pointer';
input.className = 'rounded border border-gray-800 bg-dark-700';
input.addEventListener('input', () => hwcOnColorInput());
wrap.appendChild(input);
const span = document.createElement('span');
span.textContent = slot.label;
wrap.appendChild(span);
const sub = document.createElement('span');
sub.className = 'text-gray-600';
sub.textContent = slot.sub;
wrap.appendChild(sub);
host.appendChild(wrap);
}
}
function hwcReadPickers() {
const out = {};
for (const slot of HWC_SLOTS) {
const el = document.getElementById('hwc-color-' + slot.key);
if (el) out[slot.key] = el.value;
}
return out;
}
// Live apply on any picker change. Leaves the saved-theme select alone so a
// tweaked-but-unsaved state is allowed; "Save as…" captures it.
function hwcOnColorInput() {
applyHighwayStringColors(hwcReadPickers());
}
function hwcPopulateThemeSelect() {
const sel = document.getElementById('hwc-theme-select');
if (!sel) return;
const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b));
const current = getActiveHighwayColorThemeName();
sel.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = 'Default colors';
sel.appendChild(def);
for (const n of names) {
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
sel.appendChild(opt);
}
sel.value = (current && names.includes(current)) ? current : '';
}
function hwcOnSelectTheme(name) {
selectHighwayColorTheme(name);
hwcRenderPickers();
}
async function hwcSaveTheme() {
const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' });
if (!name) return;
saveHighwayColorTheme(name, hwcReadPickers());
hwcPopulateThemeSelect();
_hwcStatus('Saved “' + name + '”');
}
function hwcDeleteTheme() {
const name = getActiveHighwayColorThemeName();
if (!name) { _hwcStatus('No saved theme selected'); return; }
deleteHighwayColorTheme(name);
applyHighwayStringColors(null);
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Deleted “' + name + '”');
}
function hwcReset() {
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
applyHighwayStringColors(null);
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Reset to defaults');
}
async function hwcCopyShare() {
const name = getActiveHighwayColorThemeName() || 'Highway Colors';
const code = encodeHighwayColorShare(name, hwcReadPickers());
let copied = false;
try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {}
if (!copied) {
// Fallback: drop the code into the import field so it can be copied manually.
const inp = document.getElementById('hwc-import-code');
if (inp) { inp.value = code; inp.select(); }
}
_hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below');
}
function hwcImport() {
const inp = document.getElementById('hwc-import-code');
const code = inp ? inp.value : '';
const res = importHighwayColorShare(code);
if (!res) { _hwcStatus('Invalid share code'); return; }
if (inp) inp.value = '';
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Imported “' + res.name + '”');
}
export function hwcInitSettingsUI() {
hwcPopulateThemeSelect();
hwcRenderPickers();
}
+99
View File
@@ -0,0 +1,99 @@
// The host seam — how a carved-out module calls back into app.js.
//
// WHY THIS EXISTS. What is left in app.js is not a tree, it is a cycle: seeding a
// dependency closure from count-in, from loops, from section-practice, or from the
// JUCE seek shim all return the SAME 178-function set, and setLoop() and
// practiceSection() call each other directly. So a module carved out of that
// component will always need to call back into app.js — and it cannot `import`
// app.js to do it, because app.js imports the module, and that closes a cycle the
// import-x/no-cycle gate (rightly) rejects.
//
// So app.js hands its functions DOWN, once, at boot: `configureHost({ playSong, … })`.
//
// ─── THE FAILURE MODE THIS IS BUILT TO PREVENT ───────────────────────────────
//
// The obvious way to write this is a plain object with no-op defaults. That is a
// TRAP, and we walked into it once already: the plugin loader's host seam defaulted
// `populateVizPicker` to `() => {}`, which means that if the wiring call in app.js
// is ever dropped, renamed, or drifts, the loader keeps running, the viz picker
// silently stops refreshing, and NOTHING — no test, no boot check, no bot — says a
// word. A feature just quietly stops existing.
//
// Two layers stop that here, and the second is the one that actually closes it:
//
// 1. RUNTIME — reading an unwired hook THROWS. There are no defaults and no
// stubs. `host.playSong` either is the real function or it is a loud error.
// An unwired hook cannot degrade into a no-op, because there is nothing for
// it to degrade INTO.
//
// 2. STATIC — tests/js/host_contract.test.js asserts that the set of hooks the
// modules USE is exactly the set app.js WIRES. This is the important one:
// layer 1 only fires if the broken path actually executes, and the whole
// danger of this seam is paths that don't run in a smoke test. The static
// check catches a drifted or misspelled hook in CI, on a path nobody ran.
//
// Consequence for anyone adding a hook: add it to the configureHost({…}) call in
// app.js *and* use it as `host.<name>`. The contract test fails on either alone —
// deliberately. A hook wired but never used is dead weight; a hook used but never
// wired is a bug that would otherwise hide.
const _hooks = Object.create(null);
let _configured = false;
/**
* Called ONCE by app.js at boot, before any carved module runs. Every value must
* be a function a hook that is accidentally `undefined` (a typo, a renamed
* export, a dropped line) fails HERE, at startup, rather than silently much later.
*/
export function configureHost(hooks) {
if (_configured) {
throw new Error('[host] configureHost() called twice — it must be wired exactly once, at boot.');
}
const bad = Object.entries(hooks || {})
.filter(([, v]) => typeof v !== 'function')
.map(([k]) => k);
if (bad.length) {
throw new Error(
`[host] these hooks are not functions: ${bad.join(', ')}. `
+ 'A hook is usually undefined because it was renamed or its line was dropped.',
);
}
Object.assign(_hooks, hooks);
_configured = true;
}
/**
* The seam itself. Reading a hook that was never wired THROWS it never returns
* undefined and never returns a silent no-op. See the note at the top: a no-op
* default is precisely the bug this module exists to make impossible.
*/
export const host = new Proxy(Object.create(null), {
get(_target, name) {
if (typeof name === 'symbol') return undefined; // let JS probe it freely
if (!_configured) {
throw new Error(
`[host] host.${name} was read before configureHost() ran. `
+ 'app.js must call configureHost() at boot, before any carved module executes.',
);
}
const fn = _hooks[name];
if (typeof fn !== 'function') {
throw new Error(
`[host] host.${name} is not wired. Add it to the configureHost({ … }) `
+ 'call in app.js. (tests/js/host_contract.test.js should have caught this in CI.)',
);
}
return fn;
},
// Keep the object honest for anything that introspects it.
has(_target, name) { return name in _hooks; },
ownKeys() { return Object.keys(_hooks); },
getOwnPropertyDescriptor(_target, name) {
return name in _hooks
? { value: _hooks[name], enumerable: true, configurable: true, writable: false }
: undefined;
},
set(_target, name) {
throw new Error(`[host] host.${String(name)} is read-only — hooks are wired only via configureHost().`);
},
});
+994
View File
@@ -0,0 +1,994 @@
// The desktop (JUCE) audio integration — three self-installing shims.
//
// The largest single slice out of app.js's core: 938 lines, ~12% of what was left.
//
// _installJuceEngineRoutingWatcher routes a song to the JUCE engine or HTML5 as the
// desktop output device enters/leaves exclusive/ASIO
// _installRendererBusFeeder feeds the highway renderer bus from whichever
// transport is actually running
// _installJuceAudioElementShim patches audio.play/pause so the rest of the app
// can keep talking to the <audio> element while JUCE
// owns the transport
//
// They EXPORT NOTHING. All three are IIFEs that publish through `window.*`
// (_juceMode, _reevaluateJuceRouting, _reevaluateRendererBus, …) — which is why app.js
// only needs a side-effect import for two of them, plus _resetJuceAudioShimChain.
//
// ORDERING, CHECKED: importing this module runs the IIFEs EARLIER than before —
// imports evaluate ahead of app.js's body, and therefore ahead of configureHost().
// That is safe because none of them touches a hook at execution depth: they only
// register listeners and patch audio.play/pause (and `audio` is itself an imported
// module now). Verified by walking the AST at IIFE-body depth. If a hook were ever
// read there it would THROW loudly — see ./host.js — rather than silently misbehave.
//
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
import { audio } from './audio-el.js';
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState } from './transport.js';
import { setSpeed } from './player-controls.js';
import { S } from './player-state.js';
(function _installJuceEngineRoutingWatcher() {
const juceApi = window.feedBackDesktop?.audio;
if (!juceApi || typeof juceApi.isAudioRunning !== 'function') {
// Desktop bridge present but audio API incomplete — the whole
// exclusive reroute chain is dead and this line is the only witness.
// (Docker sphere has no bridge at all: stay silent, nothing to
// diagnose there and no debug flag to gate on.)
if (window.feedBackDesktop) {
console.log('[asio-diag] routing watcher NOT installed (audio api incomplete)');
}
return;
}
let _rerouteInFlight = false;
// URL that JUCE's loadBackingTrack *explicitly rejected* (ok === false —
// e.g. a codec it can't read). The poll below would otherwise retry the
// same doomed track every 350 ms; remember it and skip until the song
// changes. Only a hard JUCE reject is memoised here — transient failures
// (a network blip on /api/audio-local-path, an isAudioRunning() race
// during a device restart) are deliberately NOT memoised so they retry.
let _rerouteRejectedUrl = null;
// Exclusive-style output backends silence every other client on the
// endpoint — including our own <audio> element. The share mode IS the
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
// shared and must NOT match.
function _isExclusiveOutputType(t) {
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
}
// [feedpak-route] diagnostics: log the raw outputType string once per
// value change (this runs on a 350ms poll — logging every tick would
// flood the diagnostics buffer).
let _loggedOutputType;
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
// audio.debugEnabled). Resolved once at install; until it resolves the
// flag stays false and verbose lines are skipped. Shared with the
// renderer-bus feeder below via window._asioDiagEnabled.
let _asioDiag = false;
if (typeof juceApi.debugEnabled === 'function') {
juceApi.debugEnabled().then((v) => {
_asioDiag = !!v;
// Deferred install line: the flag resolves async, so logging at
// IIFE entry would race it. Change-detection isn't needed — this
// runs once per page load.
if (_asioDiag) console.log('[asio-diag] routing watcher installed');
}).catch(() => {});
}
window._asioDiagEnabled = () => _asioDiag;
async function _outputIsExclusive() {
if (typeof juceApi.getCurrentDevice !== 'function') {
if (_loggedOutputType !== '<no-getCurrentDevice>') {
_loggedOutputType = '<no-getCurrentDevice>';
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
}
return false;
}
try {
const dev = await juceApi.getCurrentDevice();
const t = dev?.outputType || dev?.type || '';
const excl = _isExclusiveOutputType(t);
if (t !== _loggedOutputType) {
_loggedOutputType = t;
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
// [asio-diag] full device object on every type change — shows
// the exact strings the predicate saw (inputType vs outputType,
// device names, duplex), so a driver reporting a non-'ASIO'
// type name is visible in tester logs.
if (_asioDiag) {
try {
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
} catch (_) { /* circular/hostile object — skip */ }
}
}
return excl;
} catch (e) {
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
_loggedOutputType = '<getCurrentDevice-failed>';
console.warn('[feedpak-route] getCurrentDevice failed:', e);
}
return false;
}
}
// highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact
// snapshot object captured at reroute entry — i.e. the song was swapped (or
// cleared) mid-flight. Staleness is detected by object-reference identity,
// not by URL value.
function _isStale(songAudio) {
return window._currentSongAudio !== songAudio;
}
// Migrates the loaded song from the HTML5 element onto the JUCE backing
// transport. Throws only on transient/unexpected failures.
// `songAudio` is the snapshot captured at reroute entry; if it stops being
// the current song mid-flight we abort without mutating global routing.
// Returns a distinct string outcome — the caller must NOT conflate them:
// 'switched' — song now plays via JUCE.
// 'rejected' — JUCE hard-rejected the track (codec). Caller memoises it.
// 'stale' — the loaded song changed mid-flight; aborted, NOT memoised.
// (a transient transport-start failure throws instead — also not memoised.)
async function _switchHtml5ToJuce(songAudio) {
const url = songAudio.url;
const wasPlaying = S.isPlaying;
const pos = audio.currentTime || 0;
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'desktop-native',
state: 'switching',
preservedTime: true,
safeReason: 'desktop audio engine became active',
requesterId: 'core.juce-route',
});
// Mark a reroute in progress so the <audio> 'play'/'pause' listeners
// suppress their song:play / song:pause emissions: the migration is
// transparent — playback genuinely continues — so plugin state and
// window.feedBack.isPlaying must NOT flip. This also silences the
// "Audio paused unexpectedly" diagnostic. A REFCOUNT (not a boolean)
// lets an overlapping reroute's deferred release coexist: each switch
// increments on entry and decrements after its own timeout; listeners
// treat any count > 0 as "reroute active".
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
audio.pause();
try {
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
if (!res.ok) {
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
throw new Error('HTTP ' + res.status);
}
const { path } = await res.json();
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
const ok = await juceApi.loadBackingTrack(path);
if (ok === false) {
// JUCE rejected the track — stay on HTML5, resume if needed.
console.warn('[juce-reroute] loadBackingTrack rejected; staying on HTML5');
// Only resume if the element still has a source. In the normal
// flow audio.src is intact here, but a prior HTML5→JUCE switch
// clears it — re-point + load before resuming so a bounced
// reroute doesn't try to play() an empty element.
if (S.isPlaying && !_isStale(songAudio)) {
if (!audio.src) { audio.src = url; audio.load(); }
try { await audio.play(); } catch (_) { /* ignore */ }
}
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'browser-media',
state: 'degraded',
preservedTime: true,
safeReason: 'desktop audio route rejected track; kept browser media route',
requesterId: 'core.juce-route',
});
return 'rejected';
}
if (_isStale(songAudio)) return 'stale';
const dur = await juceApi.getBackingDuration();
await juceApi.seekBacking(pos);
// Start the new transport BEFORE committing global routing state, so
// a play() failure can't leave us in "JUCE mode, nothing playing"
// (the silent-song state this watcher exists to prevent).
// jucePlayer.play() RETURNS false (it does not throw) when
// startBacking fails — check the result, don't just await it.
// A play() failure is a TRANSIENT transport-start issue, not a hard
// codec reject: throw (rather than returning 'rejected') so the
// caller's catch path handles it WITHOUT memoising the URL, leaving
// it free to retry on the next poll. Only 'rejected' is memoised.
// Re-read isPlaying as late as possible: the user can press Pause
// during the multi-await fetch/IPC chain above. Starting the JUCE
// transport off a stale `wasPlaying` snapshot would resume a song
// the user just paused. Only start it if playback is still wanted.
if (S.isPlaying) {
const started = await jucePlayer.play();
if (started === false) {
if (!_isStale(songAudio) && S.isPlaying) {
try { await audio.play(); } catch (_) { /* ignore */ }
}
throw new Error('jucePlayer.play() failed (transient transport start)');
}
}
if (_isStale(songAudio)) {
// Song changed while JUCE was spinning up — undo and bail.
await jucePlayer.pause().catch(() => {});
return 'stale';
}
if (window.jucePlayer) {
jucePlayer._dur = dur;
jucePlayer._pos = pos;
jucePlayer._pollAt = performance.now();
}
window._juceMode = true;
window._juceAudioUrl = url;
const _spSlider = document.getElementById?.('speed-slider');
if (_spSlider) setSpeed(_spSlider.value / 100);
audio.src = '';
try {
const apply = window.feedBack?.audio?.applySongVolume;
if (typeof apply === 'function') await apply();
} catch (_) { /* best-effort */ }
console.log('[juce-reroute] HTML5 → JUCE @', pos.toFixed(2), 's playing=', wasPlaying);
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'desktop-native',
state: 'active',
preservedTime: true,
safeReason: 'desktop audio route active',
requesterId: 'core.juce-route',
});
return 'switched';
} catch (err) {
// Path lookup, JSON parse, or a JUCE IPC call threw partway through.
// audio.pause() already ran above; restore HTML5 playback so a
// previously playing song isn't left silently paused, then re-throw
// so the caller logs it. The caller does NOT memoise this URL —
// transient failures must retry on the next poll.
if (S.isPlaying && !window._juceMode && !_isStale(songAudio)) {
if (!audio.src) { audio.src = url; audio.load(); }
try { await audio.play(); } catch (_) { /* ignore */ }
}
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'browser-media',
state: 'degraded',
preservedTime: true,
safeReason: 'desktop audio route failed; kept browser media route',
requesterId: 'core.juce-route',
});
throw err;
} finally {
// Clearing audio.src above dispatches a 'pause' event in a later
// task, after this synchronous finally. Defer the refcount
// decrement so that trailing event is still suppressed; a 0ms
// timeout lands after the pending pause-event task. Decrementing
// (rather than zeroing) leaves any overlapping reroute's own
// suppression intact.
setTimeout(() => {
window._juceRerouteInProgress = Math.max(
0, (window._juceRerouteInProgress || 1) - 1);
}, 0);
}
}
async function _switchJuceToHtml5(songAudio) {
const url = songAudio.url;
const wasPlaying = S.isPlaying;
const pos = (window.jucePlayer ? jucePlayer.currentTime : 0) || 0;
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'browser-media',
state: 'switching',
preservedTime: true,
safeReason: 'desktop audio engine stopped',
requesterId: 'core.juce-route',
});
// Mark a reroute in progress (refcount) so the <audio> 'play' listener
// suppresses its song:play emission — the migration is transparent and
// playback genuinely continues, so plugin state must not flip. Held
// until after the (possibly deferred) audio.play() event has fired.
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
let _suppressionReleased = false;
const _releaseSuppression = () => {
if (_suppressionReleased) return;
_suppressionReleased = true;
// Defer so the 'play' (or 'pause') event task fires while still
// suppressed; a 0ms timeout lands after it.
setTimeout(() => {
window._juceRerouteInProgress = Math.max(
0, (window._juceRerouteInProgress || 1) - 1);
}, 0);
};
let _resumeScheduled = false;
try {
await jucePlayer.pause().catch(() => {});
if (_isStale(songAudio)) return; // song changed mid-pause
window._juceMode = false;
window._juceAudioUrl = null;
audio.src = url;
audio.load();
const _spSlider = document.getElementById?.('speed-slider');
if (_spSlider) setSpeed(_spSlider.value / 100);
// Resume only AFTER the seek so playback starts at `pos`, not at 0
// with an audible jump once metadata arrives.
const resumeAtPos = () => {
try {
// The metadata event can land after a fast song switch —
// bail before touching currentTime so a stale callback
// doesn't seek the newly loaded song to the old position.
if (_isStale(songAudio)) return;
try { audio.currentTime = pos; } catch (_) { /* ignore */ }
// Re-read isPlaying (not the entry snapshot): the user may
// have pressed Pause during jucePlayer.pause()/metadata
// load — don't resume a song they just paused.
if (S.isPlaying) {
audio.play().catch(() => { /* ignore */ });
}
} finally {
_releaseSuppression();
}
};
_resumeScheduled = true;
if (audio.readyState >= 1) {
resumeAtPos();
} else {
// Wait for metadata to resume at `pos`. But metadata may never
// arrive (bad URL, network error) — that would leak the
// suppression refcount and permanently silence song:play /
// song:pause. Guard with the element's 'error' event AND a
// backstop timeout; whichever fires first wins, the others are
// detached. _releaseSuppression is idempotent regardless.
let _settled = false;
const _onMeta = () => { finish(true); };
const _onErr = () => { finish(false); };
let _backstop;
function finish(reachedMetadata) {
if (_settled) return;
_settled = true;
clearTimeout(_backstop);
audio.removeEventListener('loadedmetadata', _onMeta);
audio.removeEventListener('error', _onErr);
if (reachedMetadata) {
resumeAtPos(); // resumeAtPos releases suppression
} else {
_releaseSuppression(); // no resume — just release
}
}
audio.addEventListener('loadedmetadata', _onMeta, { once: true });
audio.addEventListener('error', _onErr, { once: true });
// 10s is well beyond a normal local-file metadata load.
_backstop = setTimeout(() => { finish(false); }, 10000);
}
} finally {
// resumeAtPos owns the release once scheduled; if we returned
// early (stale, before scheduling) release here instead.
// _releaseSuppression is idempotent so an overlap is harmless.
if (!_resumeScheduled) _releaseSuppression();
}
try {
const apply = window.feedBack?.audio?.applySongVolume;
if (typeof apply === 'function') await apply();
} catch (_) { /* best-effort */ }
console.log('[juce-reroute] JUCE → HTML5 @', pos.toFixed(2), 's playing=', wasPlaying);
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'browser-media',
state: 'active',
preservedTime: true,
safeReason: 'browser media route active',
requesterId: 'core.juce-route',
});
}
async function _reevaluateJuceRouting() {
if (_rerouteInFlight) return;
const songAudio = window._currentSongAudio;
// /audio/ songs are always JUCE-routable. A feedpak full-mix
// (single-mix pack, no stems) is routable ONLY under an
// exclusive-style output — in shared mode it must stay on HTML5 so
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL.
if (window._highwayJuceRoutingPending) return;
// Claim the in-flight guard SYNCHRONOUSLY, before the first await. The
// watcher is driven by a 350ms setInterval; if isAudioRunning() (or any
// later await) stalls past the poll period, a second tick would
// otherwise pass the `if (_rerouteInFlight) return` check above and run
// a concurrent switch — duplicate loadBackingTrack IPCs racing on
// _juceMode / audio.src. Setting it here closes that window.
_rerouteInFlight = true;
try {
let running;
try { running = await juceApi.isAudioRunning(); }
catch (_) { return; }
if (_isStale(songAudio)) return; // song changed during IPC
// Eligibility is evaluated per tick, not snapshotted at song load:
// the output share mode can change mid-song (device switch in the
// Audio Engine panel), and a feedpak full-mix must follow it —
// exclusive → ride the engine; back to shared → return to HTML5.
let eligible = !!songAudio.juceEligible;
if (!eligible && songAudio.feedpakFullMix && running) {
eligible = await _outputIsExclusive();
if (_isStale(songAudio)) return; // song changed during IPC
}
const wantJuce = !!(running && eligible);
// [feedpak-route] diagnostics: one line per decision change (the
// watcher polls at 350ms; steady state must not spam the buffer).
const _decision = 'running=' + running + ' eligible=' + eligible
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
if (_decision !== window._lastFeedpakRouteDecision) {
window._lastFeedpakRouteDecision = _decision;
console.log('[feedpak-route] watcher:', _decision);
}
if (wantJuce === !!window._juceMode) return; // routing already consistent
// Don't keep retrying a track JUCE explicitly rejected.
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
if (wantJuce) {
const outcome = await _switchHtml5ToJuce(songAudio);
// Memoise ONLY an explicit hard JUCE reject. A successful
// switch clears the memo; a 'stale' abort (song changed
// mid-flight) leaves it untouched — it must never be
// misclassified as a reject, even if the song object was
// swapped and then restored before this point.
if (outcome === 'rejected') {
_rerouteRejectedUrl = songAudio.url;
} else if (outcome === 'switched') {
_rerouteRejectedUrl = null;
}
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
} else {
await _switchJuceToHtml5(songAudio);
// The engine stopped (or a feedpak's output left exclusive
// mode). Clear any hard-reject memo so a later engine restart
// or mode change re-evaluates the track at least once — the
// rejection may have been a transient device/decoder state.
_rerouteRejectedUrl = null;
}
} catch (e) {
// Transient failure — log but do NOT memoise, so the next poll retries.
console.warn('[juce-reroute] re-route failed (will retry):', e);
} finally {
_rerouteInFlight = false;
}
}
window._reevaluateJuceRouting = _reevaluateJuceRouting;
// Clears the hard-reject memo. Called from the song-teardown sites that
// null window._currentSongAudio (showScreen, playSong) so that reloading
// the same file later gets a fresh routing attempt — a prior reject may
// have been a transient JUCE/device state, not a permanent codec issue.
window._clearJuceRerouteMemo = function () { _rerouteRejectedUrl = null; };
// The engine can be started/stopped from several places (the desktop Audio
// Engine panel, the audio_engine plugin, note_detect) and via setDevice
// restarts — and the contextBridge api object is frozen, so its methods
// can't be wrapped. Poll isAudioRunning() while a song is loaded; the check
// is a cheap IPC boolean and no-ops once routing is already consistent.
// Skip the poll while the document is hidden (background tab / minimised
// window) — engine toggles there will be reconciled on the first poll
// after the tab is visible again.
setInterval(() => {
if (document.hidden) return;
if (window._currentSongAudio) void _reevaluateJuceRouting();
}, 350);
})();
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
// output endpoint in an exclusive-style mode, Chromium cannot reach the
// device, so any song audio still played by the renderer goes silent. The
// Phase 1 watcher above already migrates what a single-file transport can
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
// transport. This feeder covers the rest — the stems plugin's multi-stem
// WebAudio graph, plus <audio>-element songs the native transport could not
// take (e.g. a codec loadBackingTrack rejected).
//
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
// re-point the owning AudioContext at a null sink so it keeps rendering
// without a device, and push ~10 ms chunks over IPC into the engine's
// renderer bus, where they are mixed into the exclusive output like a
// backing track (~10-20 ms added latency on song audio only; the guitar
// monitoring path is untouched). Validated by the fix12 tester spike:
// null-sink rendering works, clocks hold (drift → 0), no overflow.
//
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
// captured contexts keep/regain their default sink.
(function _installRendererBusFeeder() {
const api = window.feedBackDesktop?.audio;
if (!api || typeof api.setRendererBus !== 'function'
|| typeof api.pushRendererAudio !== 'function') {
// Silent in the Docker sphere (no bridge, no debug flag); a desktop
// bridge missing the bus API is the diagnostic case.
if (window.feedBackDesktop) {
console.log('[asio-diag] renderer-bus feeder NOT installed (api=' + !!api
+ ' setRendererBus=' + typeof api?.setRendererBus
+ ' pushRendererAudio=' + typeof api?.pushRendererAudio + ')');
}
return;
}
// Deferred like the watcher's install line: gate on the async debug flag.
if (typeof api.debugEnabled === 'function') {
api.debugEnabled().then((v) => {
if (v) console.log('[asio-diag] renderer-bus feeder installed (loopback-capable='
+ (typeof window.navigator?.mediaDevices?.getDisplayMedia === 'function') + ')');
}).catch(() => {});
}
const TAP_WORKLET = `
class FeedbackBusTap extends AudioWorkletProcessor {
process(inputs) {
const inp = inputs[0];
if (inp && inp[0]) {
const L = inp[0], R = inp[1] || inp[0];
const out = new Float32Array(L.length * 2);
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
this.port.postMessage(out, [out.buffer]);
}
return true;
}
}
registerProcessor('feedback-bus-tap', FeedbackBusTap);
`;
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
// One tap per captured graph. `active` gates the push (the worklet keeps
// running when inactive — it's silent bookkeeping, not audio).
function _makeTap(ctx) {
const state = { node: null, active: false, batch: [], batchFrames: 0 };
state.attach = async (sourceNode) => {
if (!_tapModuleLoaded.has(ctx)) {
await ctx.audioWorklet.addModule(_tapModuleUrl);
_tapModuleLoaded.add(ctx);
}
if (!state.node) {
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
state.node.port.onmessage = (e) => {
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
state.batch.push(e.data);
state.batchFrames += e.data.length / 2;
if (state.batchFrames >= BATCH) {
const merged = new Float32Array(state.batchFrames * 2);
let o = 0;
for (const c of state.batch) { merged.set(c, o); o += c.length; }
api.pushRendererAudio(merged, ctx.sampleRate);
state.batch = []; state.batchFrames = 0;
}
};
}
sourceNode.connect(state.node);
// No onward connection: the tap is a sink-side observer; audibility
// in shared mode comes from the graph's own destination path.
};
state.detach = (sourceNode) => {
state.active = false;
state.batch = []; state.batchFrames = 0;
if (state.node && sourceNode) {
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
}
};
return state;
}
// ── Core <audio> element capture ─────────────────────────────────────────
// createMediaElementSource permanently reroutes the element into its
// context, so it is created lazily — only the first time an exclusive
// device actually needs it — and never torn down. From then on the element
// always plays through _elCtx; sink toggling routes it to the speakers
// (shared mode) or the null sink + bus (exclusive mode).
let _elCtx = null, _elSource = null, _elTap = null;
async function _ensureElementCapture() {
if (_elCtx) return;
const el = document.getElementById('audio');
if (!el) throw new Error('no core audio element');
// Assign the module state ONLY after the whole chain succeeded.
// createMediaElementSource throws InvalidStateError when another
// consumer (highway_3d's analyser tap) already owns the element's
// one-shot source — assigning _elCtx before that throw poisoned every
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
// while the song kept playing on the default device.
const ctx = new AudioContext();
let source, tap;
try {
source = ctx.createMediaElementSource(el);
source.connect(ctx.destination);
tap = _makeTap(ctx);
await tap.attach(source);
} catch (e) {
try { await ctx.close(); } catch (_) { /* already closed */ }
throw e;
}
_elCtx = ctx; _elSource = source; _elTap = tap;
}
// ── Whole-app loopback capture ───────────────────────────────────────────
// Preferred mode: one getDisplayMedia frame-audio capture covers EVERY
// sound the app makes (song, previews, UI) — no per-surface taps, so
// plugin-private AudioContexts (song-preview, future plugins) survive
// exclusive/ASIO output too. The desktop main process answers the request
// with this window's own frame (frame-scoped — no other apps' audio).
// Local playback is silenced via the suppressLocalAudioPlayback track
// constraint, with a page-mute IPC fallback (capture taps frame audio
// before the output mute, so a muted page still feeds the stream).
let _lbStream = null, _lbCtx = null, _lbTap = null, _lbPageMuted = false;
let _loopbackUnavailable = false; // sticky: probe once, then fall back
async function _engageLoopback() {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: { suppressLocalAudioPlayback: true },
});
for (const t of stream.getVideoTracks()) t.stop(); // required, unused
const track = stream.getAudioTracks()[0];
if (!track) {
for (const t of stream.getTracks()) t.stop();
throw new Error('no loopback audio track');
}
try {
// Fresh context per session (not reused) so teardown's close()
// fully releases the tap worklet node — see _teardownLoopback.
_lbCtx = new AudioContext();
if (_lbCtx.state !== 'running') await _lbCtx.resume().catch(() => {});
const source = _lbCtx.createMediaStreamSource(stream);
const tap = _makeTap(_lbCtx);
await tap.attach(source);
const suppressed = track.getSettings?.().suppressLocalAudioPlayback === true;
if (!suppressed && typeof api.setPageMuted === 'function') {
_lbPageMuted = (await api.setPageMuted(true)) === true;
}
if (window._asioDiagEnabled?.()) {
console.log('[asio-diag] loopback: suppressed=', suppressed,
'pageMuted=', _lbPageMuted, 'rate=', _lbCtx.sampleRate);
}
await api.setRendererBus(true, 1.0);
tap.active = true;
_lbStream = stream; _lbTap = tap;
_mode = 'loopback';
console.log('[renderer-bus] engaged: app loopback → engine bus');
} catch (e) {
for (const t of stream.getTracks()) t.stop();
throw e;
}
}
async function _teardownLoopback() {
if (_lbTap) _lbTap.active = false;
if (_lbStream) for (const t of _lbStream.getTracks()) t.stop();
_lbStream = null; _lbTap = null;
// Close the capture context so its tap worklet node is released. The
// context is per-session (not reused): without this, each exclusive⇄
// shared switch orphaned a live worklet on a long-lived context.
if (_lbCtx) {
try { await _lbCtx.close(); } catch (_) { /* already closed */ }
_lbCtx = null;
}
if (_lbPageMuted && typeof api.setPageMuted === 'function') {
try { await api.setPageMuted(false); } catch (_) { /* engine gone */ }
}
_lbPageMuted = false;
}
// ── Engagement state machine ─────────────────────────────────────────────
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
// loopback capture is unavailable: old desktop main, denied capture)
let _mode = 'off';
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
let _stemsTap = null;
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
let _busy = false;
async function _setSink(ctx, exclusive) {
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
// [asio-diag] a context left on the default sink while the bus is
// engaged is exactly the "song on the wrong device" symptom — record
// every successful sink flip (failures throw and are logged upstream).
if (window._asioDiagEnabled?.()) {
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
'state=', ctx.state, 'rate=', ctx.sampleRate);
}
}
async function _disengage() {
if (_mode === 'off') return;
const prev = _mode;
_mode = 'off';
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
if (prev === 'loopback') {
await _teardownLoopback();
} else if (prev === 'element' && _elCtx) {
_elTap.active = false;
await _setSink(_elCtx, false).catch(() => {});
} else if (prev === 'stems' && _stemsGraph) {
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
await _setSink(_stemsGraph.context, false).catch(() => {});
_stemsGraph = null; _stemsTap = null;
}
console.log('[renderer-bus] disengaged (' + prev + ')');
}
async function _engageStems(graph) {
await _setSink(graph.context, true);
let tap = _stemsTaps.get(graph.context);
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
await tap.attach(graph.masterNode);
await api.setRendererBus(true, 1.0);
tap.active = true;
_stemsGraph = graph; _stemsTap = tap;
_mode = 'stems';
console.log('[renderer-bus] engaged: stems graph → engine bus');
}
async function _engageElement() {
await _ensureElementCapture();
await _setSink(_elCtx, true);
await api.setRendererBus(true, 1.0);
_elTap.active = true;
_mode = 'element';
console.log('[renderer-bus] engaged: <audio> element → engine bus');
}
async function _reevaluate() {
if (_busy) return;
_busy = true;
try {
let running = false, exclusive = false;
try {
running = await api.isAudioRunning();
} catch (_) { /* engine unreachable → treat as not running */ }
if (running) {
// Reuse the Phase 1 predicate installed by the routing watcher
// (getCurrentDevice + exclusive-type check with change-logged
// diagnostics). Fail closed if it is somehow absent.
exclusive = !!(await window._juceOutputIsExclusive?.());
}
// The stems plugin publishes its live graph while a multi-stem
// song is loaded (and removes it on teardown).
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
// Element songs: a song is loaded, it is NOT riding the native
// transport (Phase 1 owns those), and the stems graph is not the
// player. Covers native-transport rejects (codec) in exclusive
// mode — without this they would be silent.
const songAudio = window._currentSongAudio;
const elementSong = !!songAudio && !window._juceMode && !stems;
let want = 'off';
if (running && exclusive) {
// Loopback covers ALL app audio (song, previews, UI), so it
// engages for the whole exclusive session — not just while a
// song is loaded. Per-surface modes remain as fallback when
// loopback capture is unavailable (old desktop main without
// the display-media handler, capture denied).
if (!_loopbackUnavailable) want = 'loopback';
else if (stems) want = 'stems';
else if (elementSong) want = 'element';
}
// Song audio riding the native transport must not ALSO ride the
// loopback (double-carry into the same engine output). The native
// transport plays from the engine, not the page, so page loopback
// never hears it — no conflict; loopback stays engaged for
// previews/UI while the transport owns the song.
// [asio-diag] full decision vector, change-gated (500ms poll —
// steady state must not flood the buffer). This is the feeder-side
// counterpart of the watcher's [feedpak-route] decision line: it
// shows WHY the bus did or didn't engage (exclusive predicate,
// stems graph presence, native transport ownership, element song).
if (window._asioDiagEnabled?.()) {
const d = 'running=' + running + ' exclusive=' + exclusive
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
+ ' juceMode=' + !!window._juceMode
+ ' elementSong=' + elementSong
+ ' loopbackUnavailable=' + _loopbackUnavailable
+ ' want=' + want + ' mode=' + _mode;
if (d !== window._lastRendererBusDecision) {
window._lastRendererBusDecision = d;
console.log('[asio-diag] renderer-bus:', d);
}
}
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
if (want !== _mode || stemsGraphChanged) {
await _disengage();
try {
if (want === 'loopback') await _engageLoopback();
else if (want === 'stems') await _engageStems(stems);
else if (want === 'element') await _engageElement();
} catch (e) {
if (want === 'loopback') {
// Capture unavailable (no handler in an old desktop
// main, permission denied) — remember and fall back to
// the per-surface modes on the next tick.
_loopbackUnavailable = true;
console.warn('[renderer-bus] loopback capture unavailable — falling back to surface taps:', e);
}
throw e;
}
}
} catch (e) {
// Explicit name/message/stack head — the console-message forward
// stringifies a DOMException to the useless "[object DOMException]".
console.warn('[renderer-bus] reevaluate failed (will retry):',
(e && e.name ? e.name + ': ' + e.message : String(e)),
(e && e.stack ? '| ' + String(e.stack).split('\n')[1] : ''));
_mode = 'off';
// A partial engage may have left the bus enabled with no producer
// and the page muted — undo both so a failed tick can't strand
// audio in silence until the next successful engage.
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
await _teardownLoopback().catch(() => {});
} finally {
_busy = false;
}
}
// Same cadence/rationale as the routing watcher above. Also re-check on
// visibility return so a device switch made while hidden is reconciled.
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
window._reevaluateRendererBus = _reevaluate;
})();
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
// (no stopBacking before seek — HTML5 needed that for buffering; JUCE does not).
export let _resetJuceAudioShimChain = function () {};
(function _installJuceAudioElementShim() {
if (!window.feedBackDesktop?.audio) return;
const mediaProto = HTMLMediaElement.prototype;
const ctDesc = Object.getOwnPropertyDescriptor(mediaProto, 'currentTime');
const pausedDesc = Object.getOwnPropertyDescriptor(mediaProto, 'paused');
if (!ctDesc?.get || !ctDesc?.set || !pausedDesc?.get) return;
const nativePlay = mediaProto.play;
const nativePause = mediaProto.pause;
let chain = Promise.resolve();
/** Same-tick pause + seek (Section Map): coalesce to one seek — no stopBacking before seek. */
let _juceShimBatch = null;
let _juceShimBatchFlushScheduled = false;
let _juceShimGen = 0;
function enqueue(fn) {
const gen = _juceShimGen;
const p = chain.then(async () => {
if (gen !== _juceShimGen) return;
return fn(gen);
});
chain = p.catch((e) => {
console.warn('[juce-audio-shim]', e);
});
return p;
}
// forUpcomingPlay: caller will enqueue a play() right after, so don't
// emit pause-state side effects for a wantsPause batch — play() will
// overwrite them anyway.
function flushJuceShimBatchNow({ forUpcomingPlay = false } = {}) {
_juceShimBatchFlushScheduled = false;
const batch = _juceShimBatch;
_juceShimBatch = null;
if (!batch || !window._juceMode) return;
const wantsPause = !!batch.wantsPause;
const seekTime = batch.seekTime;
if (wantsPause && seekTime !== undefined) {
enqueue(async (gen) => {
const r = await _audioSeek(seekTime, 'audio-element-shim');
if (!r.completed) return; // seek cancelled by teardown
if (gen !== _juceShimGen) return;
if (!forUpcomingPlay) {
await jucePlayer.pause();
if (gen !== _juceShimGen) return;
S.isPlaying = false;
setPlayButtonState(false);
const sm = window.feedBack;
if (sm) {
sm.isPlaying = false;
sm.emit('song:pause', _songEventPayload());
}
}
audio.dispatchEvent(new Event('seeked'));
});
return;
}
if (wantsPause) {
enqueue(async (gen) => {
await jucePlayer.pause();
if (gen !== _juceShimGen) return;
S.isPlaying = false;
setPlayButtonState(false);
const sm = window.feedBack;
if (sm) {
sm.isPlaying = false;
sm.emit('song:pause', _songEventPayload());
}
});
return;
}
if (seekTime !== undefined) {
enqueue(async (gen) => {
const r = await _audioSeek(seekTime, 'audio-element-shim');
if (!r.completed) return; // seek cancelled by teardown
if (gen !== _juceShimGen) return;
audio.dispatchEvent(new Event('seeked'));
});
}
}
function scheduleJuceShimBatchFlush() {
if (_juceShimBatchFlushScheduled) return;
_juceShimBatchFlushScheduled = true;
const flushGen = _juceShimGen;
queueMicrotask(() => {
if (flushGen !== _juceShimGen) {
_juceShimBatchFlushScheduled = false;
return;
}
flushJuceShimBatchNow();
});
}
_resetJuceAudioShimChain = function () {
chain = Promise.resolve();
_juceShimBatch = null;
_juceShimBatchFlushScheduled = false;
_juceShimGen++;
};
Object.defineProperty(audio, 'currentTime', {
get() {
if (window._juceMode) return jucePlayer.currentTime;
return ctDesc.get.call(this);
},
set(v) {
if (window._juceMode) {
const t = Math.max(0, Number(v) || 0);
_juceShimBatch = _juceShimBatch || {};
_juceShimBatch.seekTime = t;
scheduleJuceShimBatchFlush();
return;
}
ctDesc.set.call(this, v);
},
configurable: true,
});
Object.defineProperty(audio, 'paused', {
get() {
if (window._juceMode) return !S.isPlaying;
return pausedDesc.get.call(this);
},
configurable: true,
});
audio.pause = function () {
if (window._juceMode) {
_juceShimBatch = _juceShimBatch || {};
_juceShimBatch.wantsPause = true;
scheduleJuceShimBatchFlush();
return;
}
nativePause.call(audio);
};
audio.play = function () {
if (window._juceMode) {
if (_juceShimBatch != null) flushJuceShimBatchNow({ forUpcomingPlay: true });
const p = enqueue(async (gen) => {
const started = await jucePlayer.play();
if (gen !== _juceShimGen || !started) return;
S.isPlaying = true;
setPlayButtonState(true);
const sm = window.feedBack;
if (sm) {
sm.isPlaying = true;
const payload = _songEventPayload();
sm.emit('song:play', payload);
sm.emit('song:resume', payload);
}
});
return p.then(() => undefined);
}
return nativePlay.call(audio);
};
})();
+29
View File
@@ -0,0 +1,29 @@
// Shared, MUTABLE library state.
//
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is READ-ONLY:
// `import { _treeStats }; _treeStats = x` throws. Of the library module's 28 outward
// bindings, 23 are only ever READ from outside, so they stay plain exports. These five
// are genuinely WRITTEN from outside — by showScreen (session teardown bumps the epoch,
// resets the page), deleteSongFromModal, and syncLibrarySong, none of which can move into
// the library module because they reach the playSong/showScreen core.
//
// So exactly these five move onto an object, and no more. `L.treeStats = x` is a property
// write, which works from any module holding the same `L`. Same shape as ./player-state.js.
//
// Add to it when a carve actually needs it, not before — a container is a shared mutable
// global with better manners, and every field on it is a coupling you have to keep true.
export const L = {
/** Library tree stats (artist -> counts), cached from /api/library/tree-stats. */
treeStats: null,
/** Same, for the favourites tree. */
favTreeStats: null,
/** Tuning names, cached from /api/library/tuning-names. */
tuningNames: null,
/**
* Session generation for the library. Bumped on teardown so an in-flight page fetch
* that resolves against a stale library can't render into the new one.
*/
libEpoch: 0,
/** Current grid page (0-based). */
currentPage: 0,
};
+1988
View File
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
// The AB loop — set / clear / persist, and the saved-loops list.
//
// The second slice out of app.js's strongly-connected core, and it owns the loop
// state: loopA, loopB, _loopMutationGen. Nothing outside this module writes them
// (restartCurrentSong() looked like it did, but it declares its own local shadows).
//
// DIRECTION MATTERS HERE. loops and section-practice are mutually dependent — the
// SCC in miniature. clearLoop() has to drop section-practice's selection, and
// practiceSection() has to call setLoop(). Both directions cannot be imports or the
// no-cycle gate (rightly) rejects it. So the edge is oriented:
//
// section-practice -> reaches loops through the HOST SEAM (host.setLoop, …)
// loops -> imports section-practice DIRECTLY
//
// section-practice is the higher-level feature — it is a consumer of loops, not the
// other way round — so it is the one that gets the indirection. app.js wires this
// module's exports into the seam for it.
//
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
import { esc, uiPrompt } from './dom.js';
import { _audioSeek, _audioTime } from './transport.js';
import { formatTime } from './format.js';
import { host } from './host.js';
import {
_setSectionPracticeMode,
_syncSectionPracticeFromLoop,
_updateSectionPracticeHighlight,
practiceSection,
resetSelection,
} from './section-practice.js';
// ── A-B Loop ────────────────────────────────────────────────────────────
export let loopA = null;
export let loopB = null;
// Bumped on every NON-practiceSection loop mutation (direct setLoop from Saved
// Loops / the plugin API, and clearLoop). practiceSection() captures it and bails
// if it changes mid-retry, so a stale section retry can't overwrite a loop the
// user just set/cleared by another path. practiceSection's own setLoop calls pass
// skipSectionSync and do NOT bump it (they must not supersede themselves).
export let _loopMutationGen = 0;
export function setLoopStart() {
loopA = _audioTime();
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
}
export function setLoopEnd() {
if (loopA === null) return;
loopB = _audioTime();
if (loopB <= loopA) { loopB = null; return; }
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
// transport event so event-driven consumers (note_detect drill sync) see
// button-armed loops without having to poll getLoop().
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
}
export function clearLoop(options) {
const { emitTransportEvent = true } = options || {};
// playSong() clears the loop on every song load, so only signal a
// loop-cleared transport event when a loop was actually active —
// otherwise every song switch emits a spurious playback:loop-cleared.
const hadLoop = loopA !== null || loopB !== null;
_setSectionPracticeMode(false, { skipClearLoop: true });
loopA = null;
loopB = null;
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
document.getElementById('btn-loop-clear').classList.add('hidden');
document.getElementById('btn-loop-save').classList.add('hidden');
document.getElementById('loop-label').textContent = '';
document.getElementById('saved-loops').value = '';
resetSelection();
_updateSectionPracticeHighlight(_audioTime());
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
requesterId: 'core.loop',
reason: 'app loop cleared',
loop: { enabled: false, state: 'inactive' },
});
}
}
// Resync #saved-loops + #btn-loop-delete with the currently-active
// loopA/loopB. Used by both setLoop's success path (so plugin-driven
// loops show up correctly in the dropdown) and loadSavedLoop's
// failure path (so a cancelled selection reverts to the still-active
// loop). Without this sync, deleteSelectedLoop could target a stale
// option that doesn't match the active loop.
function _syncSavedLoopSelection() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!sel || !delBtn) return;
let selected = '';
if (loopA !== null && loopB !== null) {
for (const opt of sel.options) {
if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) {
selected = opt.value;
break;
}
}
}
sel.value = selected;
delBtn.classList.toggle('hidden', !selected);
}
// Programmatically set both loop endpoints and seek to A. The dropdown
// path (loadSavedLoop) and the plugin-API path (window.feedBack.setLoop)
// both funnel through here so the UI state stays canonical regardless of
// who triggered the loop.
//
// Returns true if the seek landed at A and the loop is now active;
// returns false if the seek was cancelled by teardown or landed off-target
// (JUCE clamp / HTML5 snap > 50ms from A). On false, loopA/loopB are NOT
// committed and the UI is not painted — the prior loop (if any) stays
// active. Throws on invalid inputs.
export async function setLoop(a, b, options) {
const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {};
const aNum = Number(a);
const bNum = Number(b);
if (!Number.isFinite(aNum) || !Number.isFinite(bNum) || bNum <= aNum) {
throw new Error(`setLoop: requires finite a and b with b > a (got a=${a}, b=${b})`);
}
// Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap
// detector (`ct >= loopB`) would trigger startCountIn against
// half-applied state.
const r = await _audioSeek(aNum, 'loop-set');
if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false;
// Caller-owned staleness gate, re-checked after the awaited seek and before
// we commit loopA/loopB. practiceSection() passes this so a superseded retry
// (newer section click, mode turned off, or song/arrangement teardown that
// happened during the seek) does not arm a stale loop. Returning false here
// leaves the prior loop (if any) untouched, same as the off-target path.
if (typeof commitGuard === 'function' && !commitGuard()) return false;
loopA = aNum;
loopB = bNum;
// A direct (non-practice) loop set supersedes any in-flight practiceSection
// retry; practiceSection passes skipSectionSync and is exempt so it doesn't
// cancel itself.
if (!skipSectionSync) _loopMutationGen++;
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
// Sync the saved-loops dropdown so a plugin-driven setLoop call
// surfaces the matching saved option (and Delete button) — otherwise
// the dropdown can stay on a stale selection and deleteSelectedLoop
// would target the wrong record.
_syncSavedLoopSelection();
// practiceSection() passes skipSectionSync: it sets its own section state
// under a request-gen guard, so the shared setLoop path must NOT re-sync
// here — otherwise a stale (superseded / mode-off) practiceSection retry
// that lands inside setLoop would re-arm the loop and flip the mode back on
// before the caller's gen check can bail. Direct callers (Saved Loops,
// window.feedBack.setLoop) still sync so their chip selection tracks.
if (!skipSectionSync && typeof _syncSectionPracticeFromLoop === 'function') {
_syncSectionPracticeFromLoop();
}
if (emitTransportEvent && typeof window !== 'undefined') {
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
}
return true;
}
export function updateLoopUI() {
const label = document.getElementById('loop-label');
const hasLoop = loopA !== null && loopB !== null;
if (hasLoop) {
label.textContent = `${formatTime(loopA)}${formatTime(loopB)}`;
document.getElementById('btn-loop-clear').classList.remove('hidden');
document.getElementById('btn-loop-save').classList.remove('hidden');
} else if (loopA !== null) {
label.textContent = `${formatTime(loopA)} → ?`;
document.getElementById('btn-loop-clear').classList.add('hidden');
document.getElementById('btn-loop-save').classList.add('hidden');
} else {
label.textContent = '';
}
host._updateEditRegionBtn();
}
export async function loadSavedLoops() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }
const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
const loops = await resp.json();
sel.innerHTML = '<option value="">Saved Loops</option>';
for (const l of loops) {
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${formatTime(l.start)}${formatTime(l.end)})</option>`;
}
if (loops.length > 0) {
sel.classList.remove('hidden');
} else {
sel.classList.add('hidden');
}
delBtn.classList.add('hidden');
}
export async function loadSavedLoop(loopId) {
const sel = document.getElementById('saved-loops');
const opt = sel.selectedOptions[0];
const delBtn = document.getElementById('btn-loop-delete');
if (!loopId || !opt?.dataset.start) {
delBtn.classList.add('hidden');
return;
}
let ok = false;
try {
// Pass raw strings — setLoop's Number() coercion is stricter than
// parseFloat (rejects "12abc") so malformed dataset values throw
// and fall into the catch instead of silently truncating.
ok = await setLoop(opt.dataset.start, opt.dataset.end);
} catch (err) {
// Malformed dataset (server returned bad data): treat the same as
// a failed seek so the dropdown resyncs and we don't propagate an
// uncaught rejection out of the onchange handler.
console.warn('[loadSavedLoop] setLoop threw:', err);
ok = false;
}
if (!ok) {
// Seek aborted, landed off-target, or input was malformed.
// Resync the dropdown with the still-active loop so the UI
// doesn't lie about which loop is loaded.
_syncSavedLoopSelection();
return;
}
// Success path: setLoop already called _syncSavedLoopSelection,
// which surfaces the delete button when the new loop matches a
// saved option (which the dropdown selection guarantees here).
}
export async function saveCurrentLoop() {
if (loopA === null || loopB === null || !host.currentFilename()) return;
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
if (name === null) return; // cancelled
const finalName = name.trim() || 'Loop'; // never persist an empty name
await fetch('/api/loops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: decodeURIComponent(host.currentFilename()),
name: finalName,
start: loopA,
end: loopB,
}),
});
await loadSavedLoops();
document.getElementById('btn-loop-save').classList.add('hidden');
}
export async function deleteSelectedLoop() {
const sel = document.getElementById('saved-loops');
const loopId = sel.value;
if (!loopId) return;
await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
clearLoop();
await loadSavedLoops();
}
+229
View File
@@ -0,0 +1,229 @@
// Player controls — the speed and mastery sliders, and the four playback preference
// reads (autoplay-exit, up-next, countdown-before-song, confirm-exit).
//
// The fourth slice out of app.js's strongly-connected core, and by far the easiest:
// ONE hook and NO shared mutable state. It is here because these three groups are the
// same surface (the controls under the highway) and all three reach the same helper.
//
// The preference reads are one-line localStorage lookups that half of app.js consults
// before deciding whether to auto-start, show the Up Next pill, run a count-in, or
// confirm on exit. They travel with the controls that set them.
//
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
import { audio } from './audio-el.js';
import { host } from './host.js';
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
// once it's ready and (b) returns to the launching menu when the song
// ends. Absence of the key means enabled. The behaviour lives in core
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
// screen, when present, is a plugin and hooks the contract below.
export function _autoplayExitEnabled() {
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
}
// ── "Up Next" pill (global option, default ON) ────────────────────────
// Gates the v3 player chrome's persistent upcoming-section pill
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
// localStorage pref (`showUpNext`); absence of the key means enabled.
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
// the pill when off.
export function _showUpNextEnabled() {
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return true; }
}
// "Countdown before song" (Gameplay tab). Mirrored to localStorage by
// loadSettings so the song-start path can read it synchronously here — no
// async /api/settings fetch on the play hot path. Defaults off.
export function _countdownBeforeSongEnabled() {
try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; }
}
export function _curPlaybackSpeed() {
try {
return window._juceMode
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
: (document.getElementById('audio')?.playbackRate || 1);
} catch (_) { return 1; }
}
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
// of leaving immediately. Auto-exit on song-end and a results screen's own
// Close never prompt — they call closeCurrentSong() directly, which stays the
// unguarded actual-exit.
export function _exitConfirmEnabled() {
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
}
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
const SPEED_SNAP_THRESHOLD = 0.02;
let _speedPresetsWired = false;
function _speedPresetPctFromActive(activePctOrRate) {
if (!Number.isFinite(activePctOrRate)) return null;
const rate = activePctOrRate <= 1.5 ? activePctOrRate : activePctOrRate / 100;
for (const pct of SPEED_PRESET_PCTS) {
if (Math.abs(rate - pct / 100) <= SPEED_SNAP_THRESHOLD) return pct;
}
return null;
}
function _updateSpeedPresetButtons(activePctOrRate) {
const wrap = document.getElementById('speed-presets');
if (!wrap) return;
const target = _speedPresetPctFromActive(activePctOrRate);
for (const btn of wrap.querySelectorAll('[data-speed-preset]')) {
const pct = Number(btn.dataset.speedPreset);
btn.classList.toggle('v3-speed-preset-active', target !== null && pct === target);
}
}
export function applySpeedPreset(percent) {
const slider = document.getElementById('speed-slider');
if (!slider) return;
const pct = Math.max(
Number(slider.min) || 15,
Math.min(Number(slider.max) || 150, Number(percent)),
);
if (!Number.isFinite(pct)) return;
slider.value = String(pct);
host.handleSliderInput(slider);
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
export function _wireSpeedPresetsOnce() {
if (_speedPresetsWired) return;
const presets = document.getElementById('speed-presets');
if (!presets) return;
_speedPresetsWired = true;
presets.addEventListener('click', (e) => {
const btn = e.target.closest('[data-speed-preset]');
if (!btn) return;
applySpeedPreset(Number(btn.dataset.speedPreset));
});
}
export function setSpeed(v) {
const speedSlider = document.getElementById('speed-slider');
const rate = Number(v);
if (!Number.isFinite(rate)) {
return;
}
if (window._juceMode) {
window.jucePlayer?.setRate(rate);
const juceAudio = window.feedBackDesktop?.audio;
Promise.resolve()
.then(() => juceAudio?.setBackingSpeed(rate))
// Match the HTML5 path: preserve pitch on the JUCE backing track too.
// Optional-chained call is a no-op on desktop builds that predate
// setBackingPreservePitch, so this is safe to ship unconditionally.
.then(() => juceAudio?.setBackingPreservePitch?.(true))
.catch(err => console.warn('[setSpeed] backing speed/preserve-pitch failed:', err));
} else {
audio.playbackRate = rate;
}
const speedLabel = document.getElementById('speed-label');
if (speedLabel) speedLabel.textContent = rate.toFixed(2) + 'x';
host.handleSliderInput(speedSlider);
_updateSpeedPresetButtons(rate);
}
export function _resetPlaybackSpeedForNewSong() {
// Reset the *actual* playback rate to 1x, not just the visible slider/label
// (feedBack#615). The HTML5 <audio> element and the desktop JUCE/backing
// engine each retain their own rate, and which one drives the next song
// isn't decided until later in the load, so reset all paths unconditionally.
// Every setter is idempotent and optional-chained, so this is safe in web
// and desktop builds alike — no need to branch on window._juceMode.
const speedSlider = document.getElementById('speed-slider');
if (speedSlider) speedSlider.value = 100;
audio.playbackRate = 1;
window.jucePlayer?.setRate?.(1);
const juceAudio = window.feedBackDesktop?.audio;
Promise.resolve()
.then(() => juceAudio?.setBackingSpeed?.(1))
.then(() => juceAudio?.setBackingPreservePitch?.(true))
.catch(err => console.warn('[resetSpeed] backing speed/preserve-pitch failed:', err));
// Mirror setSpeed's UI side-effects (label text + slider fill styling).
const speedLabel = document.getElementById('speed-label');
if (speedLabel) speedLabel.textContent = (1).toFixed(2) + 'x';
host.handleSliderInput(speedSlider);
_updateSpeedPresetButtons(100);
}
// Master-difficulty slider (feedBack#48). Persists partial via
// /api/settings — the POST handler merges only the keys present, so
// this fire-and-forget call doesn't clobber dlc_dir or other settings.
//
// Debounced trailing-edge (300ms) so dragging the slider — which fires
// oninput per pixel — doesn't flood the server with concurrent writes
// to config.json. highway.setMastery() still fires every oninput so
// the chart re-filters in real time; only disk persistence waits.
let _masteryPersistTimer = null;
function _persistMastery(pct) {
if (_masteryPersistTimer) clearTimeout(_masteryPersistTimer);
_masteryPersistTimer = setTimeout(() => {
_masteryPersistTimer = null;
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ master_difficulty: pct }),
}).catch(() => { /* best-effort — next setMastery() will retry */ });
}, 300);
}
export function setMastery(v) {
_applyMastery(v);
}
// Shared mastery applier. Master difficulty has two controls that write the
// same master_difficulty key: the player-popover slider (#mastery-slider) and
// the Gameplay-tab "Note highway speed" slider (#setting-highway-speed). Route
// both — and loadSettings' hydration — through here so their positions,
// labels, and track fills stay in sync regardless of which the user touches,
// plus the live highway re-filter and the debounced persist. All element reads
// are null-guarded since either control may be absent (follower window, or the
// settings markup not yet rendered).
export function _applyMastery(v, opts = {}) {
// Guard + clamp: v might be a slider string, a programmatic call from a
// plugin, or a restored settings value with a bad shape. Don't let NaN
// reach a label (would show "NaN%") or the POST.
const parsed = parseInt(v, 10);
if (!Number.isFinite(parsed)) return;
const pct = Math.max(0, Math.min(100, parsed));
const popLabel = document.getElementById('mastery-label');
if (popLabel) popLabel.textContent = pct + '%';
const popSlider = document.getElementById('mastery-slider');
if (popSlider) {
if (String(popSlider.value) !== String(pct)) popSlider.value = pct;
host.handleSliderInput(popSlider);
}
const setSlider = document.getElementById('setting-highway-speed');
if (setSlider) {
if (String(setSlider.value) !== String(pct)) setSlider.value = pct;
host.handleSliderInput(setSlider);
}
// The Gameplay-tab label markup appends a literal "%" after this span
// (matching the av-offset "ms" pattern), so write the number alone here —
// unlike #mastery-label above, whose markup carries no trailing unit.
const setLabel = document.getElementById('setting-highway-speed-val');
if (setLabel) setLabel.textContent = pct;
highway.setMastery(pct / 100);
if (!opts.skipPersist) _persistMastery(pct);
}
// Reflect phrase-data availability on the slider after every `ready`.
// The server omits the `phrases` message entirely for single-level
// sources (GP imports, legacy sloppak), so hasPhraseData() is the
// right signal to enable/disable the slider.
export function _applyMasteryAvailability(hasPhraseData) {
const slider = document.getElementById('mastery-slider');
if (!slider) return;
if (hasPhraseData) {
slider.disabled = false;
slider.title = 'Master difficulty — low = simpler chart, high = full';
} else {
slider.disabled = true;
slider.title = 'Source chart has a single difficulty level — slider disabled';
}
}
+42
View File
@@ -0,0 +1,42 @@
// Shared, MUTABLE player state.
//
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is read-only. Every
// slice carved out of app.js so far has only ever READ the state it shares
// (loopA/loopB, _audioSeekGen, currentFilename), so a getter hook was enough and no
// container was needed. That runs out here: count-in genuinely WRITES `isPlaying`
// (it starts and stops playback) and `lastAudioTime`. `import { isPlaying }` then
// `isPlaying = true` throws — the binding cannot be assigned to.
//
// So the state moves onto an object. `S.isPlaying = true` is a property write, which
// works from any module holding the same `S`. This is the same shape the stems,
// studio, and editor migrations converged on.
//
// It is deliberately SMALL. app.js has ~104 top-level `let` scalars; lifting all of
// them would be a ~977-site rewrite for no benefit, since most are private to one
// cluster and travel with it. Only the ones a carved module must WRITE belong here.
// Add to it when a carve actually needs it, not before.
//
// NB app.js's own 71 reference sites were rewritten mechanically — but from the AST,
// not by text substitution. Of 100 textual occurrences of these two names, only 71
// resolve to the module binding: 22 are member accesses (`someObj.isPlaying`), 4 are
// the local parameter of setPlayButtonState(isPlaying), one is an object key, and two
// are shorthand properties (`{ isPlaying }`) that must become `{ isPlaying: S.isPlaying }`.
// A blind find-and-replace corrupts all 29.
export const S = {
/** Is the transport running? Written by playback, count-in, and the JUCE shims. */
isPlaying: false,
/**
* The last audio position we saw, in seconds. Used to detect a seek that did not
* land where it was asked to (JUCE can clamp; HTML5 can round).
*/
lastAudioTime: 0,
/**
* A resume request armed by playSong({ resume }) and consumed on song:ready.
* Written by app.js (playSong, and the song:ready listener that consumes it) and
* read by the resume-session module so, like the two above, it cannot be a plain
* export.
*/
pendingResume: null,
};
+111 -1
View File
@@ -654,7 +654,8 @@ export async function loadPlugins() {
// of a cached copy keyed only by path (matches the art
// URL ?v=mtime convention elsewhere in this file).
const v = encodeURIComponent(wantedVersion);
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
const query = v ? `?v=${v}` : '';
script.src = _pluginScriptUrl(plugin, wantedVersion, query);
// Module-migration (R0): a migrated plugin declares
// scriptType:"module" and its screen.js is `import
// './src/main.js'`. A <script type="module"> fires load
@@ -802,3 +803,112 @@ export async function bootstrapPluginsAndUi() {
_streamPluginStartup();
return plugins;
}
// ── Plugin updates ──────────────────────────────────────────────────────
// The Settings-screen "Check for updates" / "Update" buttons. Carved out of
// app.js (R3a) into the loader rather than a module of their own: this is plugin
// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers,
// so app.js re-exposes them on window.
export async function checkPluginUpdates() {
const btn = document.getElementById('btn-check-updates');
const status = document.getElementById('updates-status');
const list = document.getElementById('plugin-updates-list');
btn.disabled = true;
btn.textContent = 'Checking...';
status.textContent = '';
list.innerHTML = '';
try {
const resp = await fetch('/api/plugins/updates');
const data = await resp.json();
const updates = data.updates || {};
const keys = Object.keys(updates);
if (keys.length === 0) {
status.textContent = 'All plugins are up to date.';
} else {
status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`;
for (const id of keys) {
const u = updates[id];
const row = document.createElement('div');
row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2';
row.innerHTML = `
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind ${u.local} ${u.remote})</span></span>
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
list.appendChild(row);
}
}
} catch (e) {
status.textContent = 'Failed to check for updates.';
}
btn.disabled = false;
btn.textContent = 'Check for Updates';
}
// ── Module re-evaluation (#879) ─────────────────────────────────────────────
//
// ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
// <script type="module"> whose src the module map has already seen fires `load` but
// does NOT re-run the body. So a ROLLBACK — reloading a version already evaluated
// this session — silently kept the OLD module live, while onload fired and
// loadedScripts recorded the rollback as applied. A no-op that reported success.
// (Upgrades were fine: a new version means a new ?v=, hence a new URL.)
//
// Busting the ENTRY url alone does NOT fix it. A module plugin's screen.js is a
// one-line `import './src/main.js'`, and a relative specifier resolves against the
// base URL WITH THE QUERY STRING DROPPED — so ?v= never reaches the graph, and
// src/main.js (where the plugin actually lives) stays cached no matter what we hang
// off screen.js.
//
// So the token goes in the PATH. From /api/plugins/x/g/7/screen.js, './src/main.js'
// resolves to /api/plugins/x/g/7/src/main.js — every relative import in the graph
// inherits it, at every depth, with no import-specifier rewriting (which could not
// see `import(expr)` anyway). The server ignores the token and serves identical
// bytes.
//
// ─── AND THE UPGRADE PATH WAS BROKEN TOO ────────────────────────────────────
//
// #879 says "upgrades are fine — a new version yields a new URL". That is true of
// screen.js and FALSE of the plugin. Driving a real browser through
// install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0) and counting evaluations of
// src/main.js gives ONE. Not two, not three: ONE. The upgrade re-evaluates the
// one-line screen.js shim at its new ?v= URL, that shim imports './src/main.js',
// that resolves to the same URL as before, and the module map hands back the
// ALREADY-EVALUATED v1.0.0 module. The plugin's actual code never re-ran.
//
// So the generation token is not a rollback special case. EVERY re-load of a module
// plugin needs it — the key is the plugin id, NOT id@version. Only the first load of
// a given plugin in this document takes the stable URL, which is what keeps the
// ETag/304 live-edit contract the R0 rails depend on.
const _evaluatedModules = new Set(); // plugin ids whose module graph is live in this document
let _moduleReloadSeq = 0;
function _pluginScriptUrl(plugin, wantedVersion, query) {
const base = `/api/plugins/${plugin.id}/screen.js${query}`;
if (plugin.script_type !== 'module') return base; // classic scripts always re-run
if (!_evaluatedModules.has(plugin.id)) {
_evaluatedModules.add(plugin.id);
return base; // first load: stable URL, 304-able
}
// Re-load of a module plugin — upgrade OR rollback. Its graph is already in the
// module map, so it needs an entirely fresh path or nothing below screen.js re-runs.
return `/api/plugins/${plugin.id}/g/${++_moduleReloadSeq}/screen.js${query}`;
}
export async function updatePlugin(pluginId, btn) {
btn.disabled = true;
btn.textContent = 'Updating...';
try {
const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' });
const data = await resp.json();
if (data.ok) {
btn.textContent = 'Updated — restart to apply';
btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs';
} else {
btn.textContent = 'Failed';
btn.title = data.error || '';
}
} catch (e) {
btn.textContent = 'Error';
}
}
+157
View File
@@ -0,0 +1,157 @@
// Resume last session — the snapshot taken when you leave a song, and the pill that
// offers it back.
//
// The fifth slice out of app.js's strongly-connected core. Small and self-contained:
// ONE hook (playSong) plus a currentFilename getter.
//
// The armed resume request itself lives on the shared container as S.pendingResume,
// not here, because app.js WRITES it — playSong({ resume }) arms it and the song:ready
// listener consumes it — while this module reads it. An imported binding is read-only,
// so shared mutable state has to live on the container. Same reason isPlaying does.
//
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
import { host } from './host.js';
import { _curPlaybackSpeed } from './player-controls.js';
import { S } from './player-state.js';
// ── Resume last session ────────────────────────────────────────────────────
// Leaving a song snapshots where you were — song, arrangement, position, and
// speed — so an exit (especially an accidental one, now that Escape reliably
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
// The snapshot is offered back through a non-blocking "Resume" pill; it never
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
// (This is the player-session slice; the broader nav/state-resume work — e.g.
// returning to a song after wandering into Settings → Tone Builder — is a
// separate, larger track.)
const _RESUME_KEY = 'feedBack.resumeSession';
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
// Snapshot the live session. Called from showScreen()'s teardown before
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
export function _snapshotResumeSession(position) {
try {
if (!host.currentFilename()) return;
const si = (window.highway && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const dur = Number(si.duration) || 0;
const pos = Number(position) || 0;
// Only worth resuming a song you were genuinely mid-way through — not a
// glance at the first seconds, and not one that already basically ended.
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
const snap = {
f: host.currentFilename(),
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
? si.arrangement_index : undefined,
t: pos,
sp: _curPlaybackSpeed(),
title: si.title || '',
artist: si.artist || '',
ts: Date.now(),
};
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
// A fresh snapshot earns one offer — undo any earlier dismissal.
_resumePillDismissed = false;
} catch (_) { /* storage unavailable — resume is best-effort */ }
}
export function _readResumeSession() {
try {
const raw = localStorage.getItem(_RESUME_KEY);
if (!raw) return null;
const snap = JSON.parse(raw);
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
return snap;
} catch (_) { return null; }
}
export function _clearResumeSession() {
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
}
// Re-enter the snapshotted song and restore arrangement + position + speed.
export async function resumeLastSession() {
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return false; }
_hideResumePill();
try {
await host.playSong(snap.f, snap.a, {
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
});
} catch (err) {
// A transient load/connect failure must not strand the user: keep the
// snapshot so the pill can re-offer it on the next non-player screen,
// rather than consuming the only copy before the song actually loaded.
console.warn('[app] resume failed to load; keeping snapshot:', err);
S.pendingResume = null;
return false;
}
_clearResumeSession(); // consumed only after a successful load
return true;
}
// ── Resume pill (non-blocking "continue where you left off") ────────────────
// Self-contained, inline-styled, body-appended so it works identically in the
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
// the player screen, never blocks, and a dismiss forgets the current snapshot
// for the session.
export function _hideResumePill() {
const el = document.getElementById('fb-resume-pill');
if (el) el.remove();
}
export function _maybeShowResumePill() {
const active = document.querySelector('.screen.active');
if (active && active.id === 'player') { _hideResumePill(); return; }
if (_resumePillDismissed) return;
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return; }
if (document.getElementById('fb-resume-pill')) return; // already shown
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
const pill = document.createElement('div');
pill.id = 'fb-resume-pill';
pill.setAttribute('role', 'status');
pill.style.cssText = [
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
'display:flex', 'align-items:center', 'gap:10px',
'max-width:min(90vw,360px)', 'padding:10px 12px',
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
].join(';');
const text = document.createElement('div');
text.style.cssText = 'flex:1;min-width:0';
const t1 = document.createElement('div');
t1.textContent = 'Resume practice';
t1.style.cssText = 'font-weight:600;color:#fff';
const t2 = document.createElement('div');
t2.textContent = label;
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
text.appendChild(t1); text.appendChild(t2);
const resumeBtn = document.createElement('button');
resumeBtn.type = 'button';
resumeBtn.textContent = 'Resume ▸';
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
const dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.setAttribute('aria-label', 'Dismiss');
dismissBtn.textContent = '✕';
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
pill.appendChild(text);
pill.appendChild(resumeBtn);
pill.appendChild(dismissBtn);
(document.body || document.documentElement).appendChild(pill);
}
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
// Settings backup — the export / import bundle.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then
// a best-effort localStorage merge) — the rationale comment below is the contract
// and moved with the code.
//
// Bundles server config + every localStorage key + opted-in plugin server
// files into a single JSON file.
//
// Apply semantics — phased, NOT all-or-nothing across the two stores:
// 1. Server first (/api/settings/import). Phase-1 validation guards
// the whole bundle; phase-2 disk commit is per-file but ordered
// so a mid-apply failure surfaces a `partial` field. A server
// failure short-circuits before any localStorage write, so the
// browser side stays untouched on validation refusals.
// 2. localStorage second, only after the server returns ok. Applied
// as a MERGE (no clear): bundled keys overwrite, locally-present
// keys absent from the bundle are preserved (so a plugin
// installed after the export keeps its first-run defaults).
// A localStorage exception here (quota / private mode) is
// surfaced verbatim — server state is already committed and we
// don't pretend the import was clean.
//
// In short: the server side is atomic in phase 1 and surface-partial in
// phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced.
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
try {
const resp = await fetch('/api/settings/export');
if (!resp.ok) {
status.textContent = `Export failed (HTTP ${resp.status})`;
return;
}
const bundle = await resp.json();
// Layer in the browser's localStorage. Use the standard Storage
// iteration API (length + key(i)) rather than Object.keys —
// Object.keys on a Storage instance is not deterministic across
// browsers and can both miss entries and include non-entry
// properties depending on the implementation. Keys are preserved
// verbatim as strings; that's how localStorage stores them, and
// round-trip fidelity matters more than re-typing values that
// were never typed in the first place.
const localStorageData = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key === null) continue;
const value = localStorage.getItem(key);
if (value !== null) localStorageData[key] = value;
}
bundle.local_storage = localStorageData;
// Trigger download via blob + temporary <a download>. We honor the
// server's Content-Disposition filename when present, otherwise
// fall back to a date-stamped default.
let filename = 'feedBack-settings.json';
const disposition = resp.headers.get('Content-Disposition');
if (disposition) {
const match = /filename="([^"]+)"/.exec(disposition);
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
}
}
export async function importSettings(file) {
if (!file) return;
const status = document.getElementById('backup-status');
if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) {
status.textContent = 'Import cancelled';
return;
}
let bundle;
try {
bundle = JSON.parse(await file.text());
} catch (e) {
status.textContent = `Import failed: not valid JSON (${e.message})`;
return;
}
status.textContent = 'Importing...';
let resp, data;
try {
resp = await fetch('/api/settings/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bundle),
});
data = await resp.json();
} catch (e) {
status.textContent = `Import failed: ${e.message}`;
return;
}
// Two failure shapes to surface: our own validation handler
// returns `{ok: false, error: "..."}`, but if the body fails
// FastAPI's request-level validation (e.g. top-level value is
// an array, not an object), the response is the framework's
// `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes
// both from success without depending on which path produced
// the failure.
if (!resp.ok || data.ok === false) {
let msg = data.error;
if (!msg && data.detail) {
msg = typeof data.detail === 'string'
? data.detail
: JSON.stringify(data.detail);
}
status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`;
return;
}
// Server applied successfully. Now apply the localStorage portion as
// a MERGE (not clear+restore): keys in the bundle overwrite, keys
// present locally but absent from the bundle are preserved. This
// matters when a plugin was installed *after* the export — wiping
// its localStorage would erase first-run defaults the plugin set on
// load, leaving it in a worse state than before the import. The
// tradeoff is that orphan keys from removed plugins or renamed key
// schemes also linger; cleaning those up is the user's job.
const ls = bundle.local_storage;
if (ls && typeof ls === 'object') {
try {
for (const [key, value] of Object.entries(ls)) {
if (typeof value === 'string') localStorage.setItem(key, value);
}
} catch (e) {
// Quota exceeded / private mode etc. Server side already
// committed, so we surface the partial state rather than
// pretending it succeeded.
status.textContent = `Server applied, but localStorage write failed: ${e.message}`;
return;
}
}
const warnings = (data.warnings || []).join('; ');
status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...';
setTimeout(() => location.reload(), 800);
}
+377
View File
@@ -0,0 +1,377 @@
// The playback transport — the play/pause/seek core, and the two clocks it reads.
//
// WHY THIS IS A MODULE AND NOT A HOOK BUNDLE. Every carve before this one ADDED host
// hooks: a module pulled out of app.js still had to call back into it. This one SUBTRACTS
// them. count-in, juce-audio, loops, and section-practice were all reaching through the
// seam for the same handful of names — _audioSeek, _audioTime, setPlayButtonState,
// _songEventPayload, jucePlayer. Those names have an owner, and it isn't app.js. Give
// them one and the four consumers import them directly:
//
// count-in.js 5 hooks -> 0 juce-audio.js 4 hooks -> 0
// loops.js 6 hooks -> 4 section-practice.js 10 hooks -> 7
//
// A hook is a cycle you agreed to live with. An import is a dependency you actually have.
// Prefer the import whenever the name has a real owner.
//
// TWO THINGS DELIBERATELY LEFT IN app.js, both for the same reason — they would close a
// cycle, and app.js is the root, so it can import from both sides for free:
//
// * _currentPlaybackSnapshot reads loopA/loopB from ./loops.js, and loops.js imports
// this module. The dependency scan MISSED this at first: it
// only walked app.js's own top-level decls, and loopA stopped
// being one the moment loops.js was carved out. Any scan of a
// partly-carved monolith has to resolve the imports too.
// * restartCurrentSong calls _cancelCountIn() from ./count-in.js, which imports
// this module.
//
// The seek generation (_audioSeekGen) stays PRIVATE. It has exactly one writer —
// _resetAudioSeekState(), right here — so readers get audioSeekGen() and nobody outside
// can desync it. That is strictly better than the host hook it replaces, which handed out
// a getter and left the writer in app.js.
import { audio } from './audio-el.js';
import { S } from './player-state.js';
// Sync the play/pause button's icon and accessible state in one place so
// screen readers, tooltips, and aria-pressed stay aligned with playback.
// Updates the existing <img> child's src in place rather than rewriting
// innerHTML, so any future children (fallback label, loading spinner, …)
// survive state changes.
export function setPlayButtonState(isPlaying) {
const btn = document.getElementById('btn-play');
if (!btn) return;
const label = isPlaying ? 'Pause' : 'Play';
const icon = isPlaying ? 'pause' : 'play';
let img = btn.querySelector('img.button-icon-svg');
if (!img) {
img = document.createElement('img');
img.className = 'button-icon-svg';
img.alt = '';
img.setAttribute('aria-hidden', 'true');
btn.appendChild(img);
}
img.src = `/static/svg/${icon}.svg`;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false');
btn.title = label;
}
// ── Player ───────────────────────────────────────────────────────────────
// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
// player without importing app.js back (which would close a cycle). Same
// element, same handle, same lookup — just imported instead of declared here.
let _lastSongPositionEventAt = 0;
export function _emitSongPositionChanged(time, duration) {
const now = Date.now();
if (now - _lastSongPositionEventAt < 250) return;
_lastSongPositionEventAt = now;
const payload = (typeof _songEventPayload === 'function') ? _songEventPayload() : { time };
window.feedBack.emit('song:position-changed', Object.assign(payload, { duration }));
}
export const jucePlayer = {
_timer: null,
_pos: 0,
_dur: 0,
_pollAt: 0, // performance.now() when _pos was last set
_polling: false,
_speed: 1,
get currentTime() {
if (!this._polling) return this._pos;
// Interpolate between IPC polls so highway motion is smooth at 60fps
// Scale by _speed so at 0.7x the interpolated clock advances 0.7s/s
const elapsed = (performance.now() - this._pollAt) / 1000;
return Math.min(this._pos + elapsed * this._speed, this._dur > 0 ? this._dur : Infinity);
},
get duration() { return this._dur; },
async play() {
try {
await window.feedBackDesktop.audio.startBacking();
} catch (err) {
console.warn('[jucePlayer] startBacking failed:', err);
return false;
}
this._startPolling();
return true;
},
async pause() {
// Snapshot the interpolated position before stopping the poll so
// _pos stays at the visible pause point rather than jumping back
// to the last raw IPC sample (which can be up to 100ms behind).
this._pos = this.currentTime;
this._pollAt = performance.now();
this._stopPolling();
try {
await window.feedBackDesktop.audio.stopBacking();
} catch (err) {
console.warn('[jucePlayer] stopBacking failed:', err);
}
},
async seek(s) {
const prev = this._pos;
this._pos = s;
this._pollAt = performance.now();
try {
await window.feedBackDesktop.audio.seekBacking(s);
} catch (err) {
console.warn('[jucePlayer] seekBacking failed:', err);
this._pos = prev;
this._pollAt = performance.now();
}
},
_startPolling() {
this._stopPolling();
this._polling = true;
this._pollAt = performance.now();
const self = this;
function scheduleNext() {
self._timer = setTimeout(async () => {
if (!self._polling) return;
try {
self._pos = await window.feedBackDesktop.audio.getBackingPosition();
self._pollAt = performance.now();
_emitSongPositionChanged(self.currentTime, self.duration || null);
} catch (err) {
console.warn('[jucePlayer] position poll failed:', err);
} finally {
if (self._polling) scheduleNext();
}
}, 100);
}
scheduleNext();
},
_stopPolling() {
this._polling = false;
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
},
setRate(rate) {
this._pos = this.currentTime;
this._pollAt = performance.now();
this._speed = rate;
},
async stop() {
await this.pause();
this._pos = 0;
this._dur = 0;
this._pollAt = 0;
this._speed = 1;
},
};
export function _audioTime() { return window._juceMode ? jucePlayer.currentTime : audio.currentTime; }
export function _audioDuration() { return window._juceMode ? jucePlayer.duration : audio.duration; }
// Canonical payload for song:play/song:pause/song:ended. Plugins anchor
// their own clocks against `perfNow` (a monotonic timestamp at the same
// moment audio reports `audioT`) so they don't have to chase the chart
// clock with a follow-up call. `time` is kept as an alias for `audioT`
// because pre-existing plugins read e.detail.time.
export function _songEventPayload() {
const audioT = _audioTime();
return {
time: audioT,
audioT,
chartT: highway.getTime(),
perfNow: performance.now(),
};
}
export function _markPlaybackPaused() {
S.isPlaying = false;
setPlayButtonState(false);
if (window.feedBack) {
window.feedBack.isPlaying = false;
window.feedBack.emit('song:pause', _songEventPayload());
}
}
export function _markPlaybackResumed() {
S.isPlaying = true;
setPlayButtonState(true);
if (window.feedBack) {
window.feedBack.isPlaying = true;
const payload = _songEventPayload();
window.feedBack.emit('song:play', payload);
window.feedBack.emit('song:resume', payload);
}
}
export function _emitPlaybackStopped(time, screen = 'playback-command') {
if (window.feedBack) window.feedBack.emit('song:stop', { time: time || 0, screen });
}
export function _waitForSongReady(expectedSeekGen, timeoutMs = 10000) {
if (!window.feedBack || typeof window.feedBack.on !== 'function') return Promise.resolve(false);
return new Promise(resolve => {
let timer = null;
const done = value => {
if (timer !== null) clearTimeout(timer);
window.feedBack.off('song:ready', onReady);
resolve(value);
};
const onReady = () => done(expectedSeekGen == null || expectedSeekGen === _audioSeekGen);
window.feedBack.on('song:ready', onReady);
timer = setTimeout(() => done(false), timeoutMs);
});
}
// Serializes seeks so concurrent callers (e.g. user ⏪ during a loop wrap)
// don't interleave their from/to reads — each call captures `from` only
// once the previous seek + emit have completed. The generation token
// lets session teardown invalidate queued seeks so they don't run against
// the new player and emit a stale song:seek.
let _audioSeekChain = Promise.resolve();
let _audioSeekGen = 0;
export function _resetAudioSeekState() {
// Bump the generation — in-flight chain callbacks see the mismatch on
// their next guard check and short-circuit (no emit, no further state
// mutation by us). Don't reset the chain head: new seeks must still
// queue behind the in-flight old seek's IPC so two `jucePlayer.seek()`
// calls can't race in the JUCE backing engine. The queue drains
// quickly because each subsequent old-gen step bails on the first
// guard the moment its predecessor resolves.
_audioSeekGen++;
}
// Time-box the JUCE IPC so a single hung seek can't block the global
// _audioSeekChain forever (which would freeze every subsequent reposition
// path: seekBy, loop-wrap, jump-fix, shimmed audio.currentTime).
const _JUCE_SEEK_TIMEOUT_MS = 2000;
function _juceSeekWithTimeout(s) {
let timer;
const seekP = jucePlayer.seek(s);
const timeoutP = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('JUCE seek timed out')), _JUCE_SEEK_TIMEOUT_MS);
});
// Clear the timer once the race settles either way; without this the
// pending timeout keeps the event loop alive (and eventually rejects
// an unawaited promise) even after a successful seek.
return Promise.race([seekP, timeoutP]).finally(() => clearTimeout(timer));
}
// Resolves to `{ completed, from, to }`:
// - completed: true if the seek ran to completion and emitted song:seek;
// false if cancelled by a teardown gen bump (or threw).
// - from: chart clock just before the seek (NaN on cancel before from-read).
// - to: verified post-seek clock (NaN on cancel/throw).
// Callers that fire follow-up work after the seek (count-in, arrangement
// restore, etc.) should check `completed` so they don't act on a torn-down
// session. Callers that need the actual landed position (because JUCE may
// clamp or HTML5 may snap to the seekable range) should read `to` rather
// than re-using the requested `s`.
export async function _audioSeek(s, reason) {
// Single funnel for every audio repositioning. Emits song:seek so
// plugins (notedetect detection-suppression during seek transients,
// practice-journal segment tracking) can react to any chart-time
// jump regardless of which UI path triggered it. `reason` is a
// free-form short string ('seek-by', 'loop-wrap', 'loop-set',
// 'arrangement-restore', 'jump-fix') so subscribers can filter.
const gen = _audioSeekGen;
_audioSeekChain = _audioSeekChain.then(async () => {
if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN };
const from = _audioTime();
if (window._juceMode) await _juceSeekWithTimeout(s);
else audio.currentTime = s;
if (gen !== _audioSeekGen) return { completed: false, from, to: NaN };
// Read the verified post-seek position rather than the requested `s`
// so plugins observe the actual clock — JUCE may clamp or roll back,
// and HTML5 may snap to the nearest seekable range.
const to = _audioTime();
// Sync the jump-fix tracker so the next 60Hz tick doesn't see a
// legitimate far seek (e.g. saved-loop jump > 30s) as a browser
// bug and revert it.
S.lastAudioTime = to;
// Sync the chart clock too so any song:* emit fired right after
// _audioSeek resolves (e.g. the auto-resume song:play in
// changeArrangement) sees an in-sync chartT via _songEventPayload.
// Without this, chartT lags by one 60Hz tick after a seek.
if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
highway.setTime(to);
}
window.feedBack.emit('song:seek', { from, to, reason: reason || null });
return { completed: true, from, to };
}).catch((err) => {
// Don't let one failed seek poison subsequent ones.
console.warn('[_audioSeek]', err);
return { completed: false, from: NaN, to: NaN };
});
return _audioSeekChain;
}
// Per-attempt counter for HTML5 audio.play() invocations. Bumped on
// every play branch entry so a slow rejection from attempt N can't
// clobber the UI of a newer attempt N+1 within the same session.
let _playAttemptGen = 0;
export async function togglePlay() {
if (window._juceMode) {
if (S.isPlaying) {
await jucePlayer.pause();
S.isPlaying = false;
setPlayButtonState(false);
window.feedBack.isPlaying = false;
window.feedBack.emit('song:pause', _songEventPayload());
} else {
const started = await jucePlayer.play();
if (!started) return; // startBacking() failed — IPC error already logged
S.isPlaying = true;
setPlayButtonState(true);
window.feedBack.isPlaying = true;
const payload = _songEventPayload();
window.feedBack.emit('song:play', payload);
window.feedBack.emit('song:resume', payload);
}
return;
}
if (S.isPlaying) {
audio.pause(); S.isPlaying = false;
setPlayButtonState(false);
} else {
// Flip the UI optimistically before awaiting the play() Promise so
// a quick second click during a slow start (buffering, device
// wake, etc.) still enters the pause branch above. Two stale-
// resolution guards:
// - _audioSeekGen: bumped in showScreen() teardown and
// playSong(), so a rejection from a torn-down session can't
// touch new-session UI. Survives same-URL reloads.
// - _playAttemptGen: bumped on every play branch entry, so
// within a single session a slow rejection from attempt N
// can't clobber a faster attempt N+1 (Play → Pause → Play).
const sessionGen = _audioSeekGen;
const attempt = ++_playAttemptGen;
S.isPlaying = true;
setPlayButtonState(true);
try {
await audio.play();
} catch (err) {
if (sessionGen !== _audioSeekGen) return;
if (attempt !== _playAttemptGen) return;
// An engine reroute (HTML5 -> JUCE) deliberately pauses the <audio>
// element mid-migration, which rejects this in-flight play() with an
// AbortError even though playback continues on the JUCE transport.
// The reroute owns isPlaying / the button while it runs (same guard
// the <audio> 'play'/'pause' listeners use); resetting here would
// leave the button showing Play while the song keeps playing — the
// "two clicks to pause on the first song after a fresh load" bug.
if (window._juceRerouteInProgress) return;
console.error('[app] audio.play() rejected:', err);
S.isPlaying = false;
setPlayButtonState(false);
}
}
}
export async function seekBy(s) {
await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by');
}
/**
* Read-only view of the seek generation. Bumped by _resetAudioSeekState() on session
* teardown; callers capture it before an await and compare after, so a resolution from a
* torn-down session can't touch new-session state.
*/
export function audioSeekGen() { return _audioSeekGen; }
+228
View File
@@ -0,0 +1,228 @@
// Tuning display — naming, string counts, and target frequencies.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
// bass, its effective string count, and the target FREQUENCIES + note names the
// tuner checks against. Pure functions over a small MIDI/note-name table.
//
// The window / window.feedBack assignments for these stay in app.js — they are the
// public contract (constitution II names window.feedBack), and app.js re-exposes
// the imported bindings from exactly where it always did, so nothing about the
// surface or its ordering changes.
// Display-only tuning label helpers — never mutate offsets or affect playback.
function _looksLikeRawTuningOffsets(str) {
if (!str || typeof str !== 'string') return false;
const s = str.trim();
if (!s) return false;
if (/^-?\d+$/.test(s)) return true;
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
return false;
}
function _tuningNameFromOffsets(offsets) {
if (!offsets || !offsets.length) return '';
const standard = {
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
'-6': 'Bb Standard', '-7': 'A Standard',
1: 'F Standard', 2: 'F# Standard',
};
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
const name = standard[offsets[0]];
if (name) return name;
}
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
&& offsets.slice(1).every((o) => o === offsets[1])) {
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
}
const named = {
'-2,0,0,0,0,0': 'Drop D',
'-4,-2,-2,-2,-2,-2': 'Drop C',
'-2,-2,0,0,0,0': 'Double Drop D',
'0,0,0,-1,0,0': 'Open G',
'-2,-2,0,0,-2,-2': 'Open D',
'-2,0,0,0,-2,0': 'DADGAD',
'0,2,2,1,0,0': 'Open E',
'-2,0,0,2,3,2': 'Open D (alt)',
};
if (offsets.length === 6) {
const key = offsets.join(',');
if (named[key]) return named[key];
}
return 'Custom Tuning';
}
export function displayTuningName(value, offsets) {
// Explicit offsets win — always name them.
if (Array.isArray(offsets) && offsets.length > 0) {
return _tuningNameFromOffsets(offsets);
}
if (value && typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed || trimmed === 'Unknown') return '';
if (!_looksLikeRawTuningOffsets(trimmed)) {
return trimmed;
}
// A raw offset string (now served by the API) — parse and name it so a
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
// collapsing to "Custom Tuning".
const parsed = (typeof parseRawTuningOffsets === 'function')
? parseRawTuningOffsets(trimmed) : null;
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
return 'Custom Tuning';
}
return '';
}
export function isBassArrangement(context) {
const ctx = context && typeof context === 'object' ? context : {};
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
if (/\bbass\b/.test(label)) return true;
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
return false;
}
export function effectiveStringCount(offsets, context) {
if (!Array.isArray(offsets) || !offsets.length) return 0;
const ctx = context && typeof context === 'object' ? context : {};
const isBass = isBassArrangement(ctx);
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
if (!isBass) {
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
} else if (!sc) {
sc = offsets.length >= 5 ? offsets.length : 4;
}
return Math.min(sc, offsets.length);
}
export function songTuningContext(songInfo) {
if (!songInfo || typeof songInfo !== 'object') return {};
return {
stringCount: songInfo.stringCount,
arrangement: songInfo.arrangement,
arrangement_smart_name: songInfo.arrangement_smart_name,
};
}
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
const _TUNING_BASE_MIDI = {
4: [28, 33, 38, 43],
5: [23, 28, 33, 38, 43],
6: [40, 45, 50, 55, 59, 64],
7: [35, 40, 45, 50, 55, 59, 64],
8: [30, 35, 40, 45, 50, 55, 59, 64],
};
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
function _tuningMidiToFreq(m) {
return Math.pow(2, (m - 69) / 12) * 440;
}
function _tuningOffsetsToFreqs(offsets, isBass) {
const len = offsets.length;
let base;
if (len === 4 || len === 5) {
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
} else {
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
}
return offsets.map((offset, i) => {
const root = i < base.length ? base[i] : base[base.length - 1];
return _tuningMidiToFreq(root + offset);
});
}
function _noteNameFromFreq(freq, useFlats) {
const midi = 69 + 12 * Math.log2(freq / 440);
const rounded = Math.round(midi);
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
return names[((rounded % 12) + 12) % 12];
}
function _octaveNoteFromFreq(freq, useFlats) {
const midi = 69 + 12 * Math.log2(freq / 440);
const rounded = Math.round(midi);
const octave = Math.floor(rounded / 12) - 1;
return _noteNameFromFreq(freq, useFlats) + octave;
}
function _stringOrdinalLabel(n) {
const v = n % 100;
if (v >= 11 && v <= 13) return n + 'th';
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
return n + suffix;
}
function _tuningTargetFreqs(offsets, context) {
if (!Array.isArray(offsets) || !offsets.length) return [];
const ctx = context && typeof context === 'object' ? context : {};
const stringCount = effectiveStringCount(offsets, ctx);
const trimmed = offsets.slice(0, stringCount);
if (!trimmed.length) return [];
const isBass = isBassArrangement(ctx);
try {
return _tuningOffsetsToFreqs(trimmed, isBass);
} catch (_) {
return [];
}
}
// Flat vs sharp spelling. A caller that knows the preference can pass
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
// to sharps unless an explicit useFlats is supplied.
function _resolveTargetUseFlats(ctx) {
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
}
export function displayTuningTargetDetails(offsets, context) {
const ctx = context && typeof context === 'object' ? context : {};
const useFlats = _resolveTargetUseFlats(ctx);
const freqs = _tuningTargetFreqs(offsets, ctx);
return freqs.map((f, i) => {
const stringNumber = freqs.length - i;
const note = _noteNameFromFreq(f, useFlats);
const octaveNote = _octaveNoteFromFreq(f, useFlats);
return {
stringNumber,
note,
octaveNote,
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
};
});
}
export function displayTuningTargets(offsets, context) {
const ctx = context && typeof context === 'object' ? context : {};
const useFlats = _resolveTargetUseFlats(ctx);
const freqs = _tuningTargetFreqs(offsets, ctx);
if (!freqs.length) return '';
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
}
export function parseRawTuningOffsets(value) {
if (Array.isArray(value) && value.length) return value;
if (!value || typeof value !== 'string') return null;
const s = value.trim();
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
return s.split(/\s+/).map((n) => Number(n));
}
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
return s.split(',').map((n) => Number(n));
}
return null;
}
+1
View File
@@ -1275,6 +1275,7 @@
saved 'off'/'full' motion preference on first paint. -->
<script defer src="/static/v3/venue-mood-fx.js"></script>
<script defer src="/static/v3/venue-scene-3d.js"></script>
<script defer src="/static/v3/venue-crowd.js"></script>
<script defer src="/static/v3/playlists.js"></script>
<script defer src="/static/v3/audio-routing.js"></script>
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
+637
View File
@@ -0,0 +1,637 @@
/*
* fee[dB]ack Venue crowd video layer (career mode PR1).
*
* Crossfades pre-rendered crowd-state loop videos behind the highway based on
* v3:live-performance-state, plus one-shot reaction stingers. Renders through
* two video backdrop planes owned by the highway_3d venue background style
* (window.h3dVenueBackdropSetVideo / window.h3dVenueBackdropSetMix).
*
* Inert unless a venue pack manifest is set by the career plugin via
* v3VenueCrowd.setManifest(), or (dev only) a JSON manifest in localStorage
* under feedBack-venue-crowd-dev. With no manifest the static bg plate
* behaves exactly as before.
*/
(function (root) {
'use strict';
// live-performance-hud state → crowd state.
const CROWD_OF_PERF = {
smoke: 'bored',
recovery: 'bored',
idle: 'neutral',
steady: 'neutral',
strong: 'engaged',
fire: 'ecstatic',
};
const CROWD_STATES = ['bored', 'neutral', 'engaged', 'ecstatic'];
const CROWD_RANK = { bored: 0, neutral: 1, engaged: 2, ecstatic: 3 };
const STABLE_MS = 3000; // target must hold this long before a switch
const DWELL_MS = 8000; // min time between committed switches
const FADE_MS = 1200; // loop crossfade
const STINGER_FADE_MS = 400; // stinger fade-in/out
const STINGER_MIN_GAP_MS = 20000;
const STREAK_MILESTONES = [25, 50, 100];
const CANPLAY_TIMEOUT_MS = 4000;
const DEV_FLAG_KEY = 'feedBack-venue-crowd-dev';
const SFX_KEY = 'feedBack-venue-crowd-sfx'; // 'on' | 'off' (default off)
// ---------------------------------------------------------------------
// Pure, clock-injected decision logic (unit-tested in
// tests/js/venue_crowd.test.js — keep DOM-free).
// ---------------------------------------------------------------------
function crowdStateOfPerf(perfState) {
return CROWD_OF_PERF[String(perfState || '').toLowerCase()] || 'neutral';
}
// Hysteresis: a new target must be observed continuously for STABLE_MS,
// and at least DWELL_MS must have passed since the last committed switch.
function createCrowdMachine() {
let current = 'neutral';
let candidate = null;
let candidateSince = 0;
let lastSwitchAt = -Infinity;
return {
get current() { return current; },
reset() {
current = "neutral";
candidate = null;
lastSwitchAt = -Infinity;
},
// Feed the latest perf state; returns the new crowd state when a
// transition commits, else null.
update(perfState, nowMs) {
const target = crowdStateOfPerf(perfState);
if (target === current) {
candidate = null;
return null;
}
if (target !== candidate) {
candidate = target;
candidateSince = nowMs;
return null;
}
if (nowMs - candidateSince < STABLE_MS) return null;
if (nowMs - lastSwitchAt < DWELL_MS) return null;
current = target;
candidate = null;
lastSwitchAt = nowMs;
return current;
},
};
}
// Cheer when the streak crosses a milestone (rising edge only).
function stingerForStreak(prevStreak, streak) {
for (const m of STREAK_MILESTONES) {
if (prevStreak < m && streak >= m) return 'cheer';
}
return null;
}
// End-of-song reaction from final accuracy.
function stingerForAccuracy(accuracyPct) {
const a = Number(accuracyPct);
if (!Number.isFinite(a)) return null;
if (a >= 90) return 'cheer';
if (a >= 75) return 'clap';
return null;
}
// ---------------------------------------------------------------------
// Video layer controller (browser only).
// ---------------------------------------------------------------------
const machine = createCrowdMachine();
let _manifest = null; // { loops: {state: url}, stingers: {name: url} }
let _venueActive = false;
let _videos = [null, null];
let _activeLayer = 0; // layer currently showing the loop
let _mix = 0; // 0 → layer0 visible, 1 → layer1 visible
let _fadeRaf = 0;
let _stopGen = 0; // bumped by stop(): invalidates ALL in-flight loads
let _boundToRenderer = false;
let _pendingLoop = null; // loop switch deferred by an active stinger
let _loadingLoop = null; // loop currently waiting on canplaythrough
let _fadingLoop = null; // loop currently crossfading in (not yet active)
let _stingerUntilEnded = false;
let _stingerGen = 0; // identity for ended/timeout handlers
let _introActive = false;
let _introGen = 0;
let _audioEl = null; // crowd ambience during the intro flyover
let _audioFadeTimer = 0;
let _lastStingerAt = -Infinity;
let _prevStreak = 0;
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
let _bound = false;
function now() { return Date.now(); }
function h3d(name) {
return root && typeof root[name] === 'function' ? root[name] : null;
}
function normalizeManifest(m) {
if (!m || typeof m !== 'object' || !m.loops) return null;
const base = typeof m.base === 'string' ? m.base : '';
const abs = (u) => (typeof u === 'string' && u ? base + u : '');
const loops = {};
for (const s of CROWD_STATES) loops[s] = abs(m.loops[s]);
if (!CROWD_STATES.every((s) => loops[s])) return null;
const stingers = {};
for (const k of ['clap', 'cheer']) stingers[k] = abs(m.stingers && m.stingers[k]);
const intro = {
video: abs(m.intro && m.intro.video),
audio: abs(m.intro && m.intro.audio),
};
const sfx = {
up: abs(m.sfx && m.sfx.up),
down: abs(m.sfx && m.sfx.down),
};
return { loops, stingers, intro, sfx };
}
function ensureVideos() {
if (!_videos[0] && typeof document !== 'undefined') {
for (let i = 0; i < 2; i++) {
const v = document.createElement('video');
// Same autoplay-safe recipe as the highway_3d video bg style:
// muted + playsInline bypasses gesture requirements; same-origin
// URLs so VideoTexture never taints.
v.muted = true;
v.playsInline = true;
v.preload = 'auto';
v.loop = true;
v.style.display = 'none';
document.body.appendChild(v);
_videos[i] = v;
}
}
bindVideosToRenderer();
}
// The highway_3d plugin (and its globals) can register after the venue
// pack starts — e.g. Venue selected at page load, renderer ready later.
// Idempotent and retried from start() and the perf-event path so a late
// renderer still picks the videos up.
function bindVideosToRenderer() {
if (_boundToRenderer || !_videos[0]) return;
const setVideo = h3d('h3dVenueBackdropSetVideo');
if (!setVideo) return;
setVideo(0, _videos[0]);
setVideo(1, _videos[1]);
_boundToRenderer = true;
setMix(_mix); // re-push mix the renderer missed while unregistered
}
function setMix(v) {
_mix = Math.max(0, Math.min(1, v));
const fn = h3d('h3dVenueBackdropSetMix');
if (fn) fn(_mix);
}
function cancelFade() {
if (_fadeRaf && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(_fadeRaf);
}
_fadeRaf = 0;
}
function fadeMixTo(target, durationMs, done) {
cancelFade();
if (typeof requestAnimationFrame !== 'function') {
setMix(target);
if (done) done();
return;
}
const from = _mix;
const t0 = now();
const step = () => {
const k = Math.min(1, (now() - t0) / durationMs);
setMix(from + (target - from) * k);
if (k < 1) {
_fadeRaf = requestAnimationFrame(step);
} else {
_fadeRaf = 0;
if (done) done();
}
};
_fadeRaf = requestAnimationFrame(step);
}
// Load url into the video, resolve when it can play through (or after a
// timeout — a stalled fetch must not wedge the crowd forever). Tokens are
// per-element: a later load on the SAME video (a stinger preempting the
// idle layer) cancels this one, but loads on the other layer don't.
function loadAndPlay(video, url, loop, cb) {
const token = (video._fbCrowdToken = (video._fbCrowdToken || 0) + 1);
const gen = _stopGen;
let settled = false;
const settle = (ok) => {
if (settled) return;
settled = true;
// Cleanup must run even for superseded loads or stale listeners
// accumulate on the two persistent elements; only the callback
// is gated on still being the current load.
video.removeEventListener('canplaythrough', onReady);
video.removeEventListener('error', onError);
if (token !== video._fbCrowdToken || gen !== _stopGen) return;
cb(ok);
};
const onReady = () => settle(true);
const onError = () => settle(false);
video.addEventListener('canplaythrough', onReady);
video.addEventListener('error', onError);
video.loop = loop;
video.src = url;
video.play().catch(() => { /* browser retries on visibility/gesture */ });
setTimeout(() => settle(video.readyState >= 3), CANPLAY_TIMEOUT_MS);
}
function idleLayer() { return _activeLayer === 0 ? 1 : 0; }
// Crossfade the loop for `state` in on the idle layer.
function showLoop(state, fadeMs) {
if (!_manifest || !_videos[0]) return;
const layer = idleLayer();
const video = _videos[layer];
_loadingLoop = state;
loadAndPlay(video, _manifest.loops[state], true, (ok) => {
if (_loadingLoop === state) _loadingLoop = null;
if (!ok || !_venueActive) return;
_fadingLoop = state;
fadeMixTo(layer === 1 ? 1 : 0, fadeMs, () => {
// Preempted mid-fade (stinger claimed this layer while we
// were still ramping): the layer no longer holds this loop —
// promoting it would pause the real loop and hand fade-back
// the wrong target.
if (_fadingLoop !== state) return;
_fadingLoop = null;
const old = _videos[_activeLayer];
_activeLayer = layer;
if (old && !old.paused) old.pause();
});
});
}
function playStinger(name) {
if (!_manifest || !_manifest.stingers[name] || !_videos[0]) return;
if (_stingerUntilEnded) return;
const t = now();
if (t - _lastStingerAt < STINGER_MIN_GAP_MS) return;
_lastStingerAt = t;
_stingerUntilEnded = true;
const layer = idleLayer();
const video = _videos[layer];
// The stinger reuses the idle layer's element, cancelling any loop
// load still in flight there — and idleLayer() is still the fading-in
// layer while a crossfade runs (_activeLayer flips on completion), so
// a mid-fade loop gets overwritten too. Requeue either for when the
// stinger ends (the machine already advanced, nothing re-fires it).
const interrupted = _loadingLoop || _fadingLoop;
if (interrupted) {
// Freeze any in-flight crossfade: its ramp would keep pushing the
// mix toward this layer while the stinger replaces the src (loop
// vanishing / stinger popping in at full opacity).
cancelFade();
_pendingLoop = interrupted;
_loadingLoop = null;
_fadingLoop = null;
}
// A loop switch deferred (or preempted) by this stinger must play
// once the stinger is done OR failed — the machine already advanced,
// so nothing re-triggers it later.
const flushPending = () => {
if (!_pendingLoop || !_venueActive) return;
const pending = _pendingLoop;
_pendingLoop = null;
showLoop(pending, FADE_MS);
};
const myGen = ++_stingerGen;
const back = () => {
// Always detach: a handler left behind by a stop()/manifest swap
// must not fire into a LATER stinger's lifecycle on this reused
// element (the gen check below guards that; the boolean alone
// would pass once a new stinger is active).
video.removeEventListener('ended', back);
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
_stingerUntilEnded = false;
// Fade back to the loop layer (which kept playing underneath).
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
flushPending();
};
loadAndPlay(video, _manifest.stingers[name], false, (ok) => {
if (!ok || !_venueActive) {
_stingerUntilEnded = false;
flushPending();
return;
}
video.addEventListener('ended', back);
fadeMixTo(layer === 1 ? 1 : 0, STINGER_FADE_MS);
// Safety: an `ended` that never fires (decode stall) must not
// freeze the crowd on a stinger frame.
setTimeout(back, 15000);
});
}
function ensureAudio() {
if (_audioEl || typeof document === 'undefined') return;
_audioEl = document.createElement('audio');
_audioEl.preload = 'auto';
_audioEl.style.display = 'none';
document.body.appendChild(_audioEl);
}
function fadeAudioOut(durationMs) {
if (!_audioEl || _audioEl.paused) return;
if (_audioFadeTimer) return; // already fading
const from = _audioEl.volume;
const t0 = now();
_audioFadeTimer = setInterval(() => {
const k = Math.min(1, (now() - t0) / durationMs);
_audioEl.volume = from * (1 - k);
if (k >= 1) {
clearInterval(_audioFadeTimer);
_audioFadeTimer = 0;
_audioEl.pause();
}
}, 50);
}
function stopAudio() {
if (_audioFadeTimer) { clearInterval(_audioFadeTimer); _audioFadeTimer = 0; }
if (_audioEl && !_audioEl.paused) _audioEl.pause();
}
// One-shot flyover intro on song load: video flies from the back of the
// room onto the stage, crowd ambience plays and ducks out as the song
// starts (song:play) or as the flyover lands, whichever comes first.
function playIntro() {
if (!_manifest || !_manifest.intro || !_manifest.intro.video || !_videos[0]) {
return false;
}
const myGen = ++_introGen;
_introActive = true;
const layer = idleLayer();
const video = _videos[layer];
const land = () => {
if (_introGen !== myGen || !_introActive) return;
_introActive = false;
video.removeEventListener('ended', land);
fadeAudioOut(1200);
const pending = _pendingLoop;
_pendingLoop = null;
showLoop(pending || machine.current, 400);
};
loadAndPlay(video, _manifest.intro.video, false, (ok) => {
if (_introGen !== myGen) return;
if (!ok || !_venueActive) {
// Failed intro must not leave the song loop-less: fall back
// to the normal loop exactly like the no-intro path.
_introActive = false;
if (_venueActive) showLoop(machine.current, FADE_MS);
return;
}
fadeMixTo(layer === 1 ? 1 : 0, 300);
video.addEventListener('ended', land);
setTimeout(land, 15000); // decode-stall safety
if (_manifest.intro.audio) {
ensureAudio();
_audioEl.src = _manifest.intro.audio;
_audioEl.volume = 1;
// The user's play gesture precedes song:loaded, so autoplay
// with sound is normally allowed; degrade silently if not.
_audioEl.play().catch(() => { /* no gesture yet */ });
// start ducking shortly before the flyover lands
video.addEventListener('timeupdate', function duck() {
if (video.duration && video.duration - video.currentTime < 1.5) {
video.removeEventListener('timeupdate', duck);
fadeAudioOut(1400);
}
});
}
});
return true;
}
let _sfxEl = null;
function sfxEnabled() {
try { return localStorage.getItem(SFX_KEY) === 'on'; } catch (_) { return false; }
}
// One-shot crowd reaction on committed mood transitions (toggleable):
// up the ladder → cheer, down → boos. Committed transitions are already
// hysteresis-limited, so this can't spam.
function playMoodSfx(direction) {
if (!sfxEnabled() || !_manifest || !_manifest.sfx || _introActive) return;
const url = direction > 0 ? _manifest.sfx.up : _manifest.sfx.down;
if (!url || typeof document === 'undefined') return;
if (!_sfxEl) {
_sfxEl = document.createElement('audio');
_sfxEl.preload = 'auto';
_sfxEl.style.display = 'none';
document.body.appendChild(_sfxEl);
}
_sfxEl.src = url;
_sfxEl.volume = 0.6;
_sfxEl.play().catch(() => { /* pre-gesture; skip silently */ });
}
function onSongPlay() {
// Song audio starting is the hard cue: the ambience must yield.
fadeAudioOut(1000);
}
function onPerformanceState(e) {
if (!_venueActive || !_manifest) return;
bindVideosToRenderer();
const d = (e && e.detail) || {};
// Number(null) === 0: HUD reset events (accuracyPct: null) must not
// wipe the value the end-of-song stinger reads via stats:recorded.
if (d.accuracyPct != null && Number.isFinite(Number(d.accuracyPct))) {
_lastAccuracyPct = Number(d.accuracyPct);
}
const streak = Number(d.streak) || 0;
const sting = stingerForStreak(_prevStreak, streak);
_prevStreak = streak;
if (sting && !_introActive && CROWD_RANK[machine.current] >= CROWD_RANK.neutral) {
playStinger(sting);
}
const prevRank = CROWD_RANK[machine.current];
const next = machine.update(d.state, now());
if (next) {
playMoodSfx(CROWD_RANK[next] - prevRank);
// A stinger or the intro owns the idle layer; defer the switch.
if (_stingerUntilEnded || _introActive) _pendingLoop = next;
else showLoop(next, FADE_MS);
}
}
function onSongLoaded() {
machine.reset();
_prevStreak = 0;
_lastAccuracyPct = null;
// Abort any stinger/pending state from the previous song: its ended
// handler must not fade back into the old song's layers.
cancelFade();
_stingerGen++;
_introGen++;
_stingerUntilEnded = false;
_introActive = false;
stopAudio();
_pendingLoop = null;
_loadingLoop = null;
_fadingLoop = null;
if (_venueActive && _manifest) {
if (!playIntro()) showLoop(machine.current, FADE_MS);
}
}
function onStatsRecorded() {
if (!_venueActive || !_manifest) return;
// stats:recorded carries only {filename, arrangement} — the accuracy
// comes from the last v3:live-performance-state of the finished song.
const sting = stingerForAccuracy(_lastAccuracyPct);
_lastAccuracyPct = null; // one reaction per song
if (sting) {
_lastStingerAt = -Infinity; // end-of-song reaction always allowed
playStinger(sting);
}
}
function start() {
ensureVideos();
if (!_videos[0]) return;
_prevStreak = 0;
// Boot straight into the current machine state on the active layer.
const video = _videos[_activeLayer];
loadAndPlay(video, _manifest.loops[machine.current], true, (ok) => {
if (!ok || !_venueActive) return;
setMix(_activeLayer === 1 ? 1 : 0);
});
}
function stop() {
cancelFade();
_stopGen++;
_stingerGen++;
_introGen++;
_introActive = false;
stopAudio();
if (_sfxEl && !_sfxEl.paused) _sfxEl.pause();
_stingerUntilEnded = false;
_pendingLoop = null;
_loadingLoop = null;
_fadingLoop = null;
for (const v of _videos) {
if (v && !v.paused) v.pause();
}
// Unbind from the renderer: a paused video still holds its last
// frame, and the venue style keeps a bound plane visible whenever
// videoWidth > 0 — without this a removed pack would leave a frozen
// crowd frame over the static plate. start() re-binds.
const setVideo = h3d('h3dVenueBackdropSetVideo');
if (_boundToRenderer && setVideo) {
setVideo(0, null);
setVideo(1, null);
}
_boundToRenderer = false;
// Mix and active layer must reset together: mix 0 shows layer 0, so a
// restart that left _activeLayer at 1 would flash layer 0's stale
// frame until the new loop loads.
_activeLayer = 0;
setMix(0);
}
function setVenueActive(on) {
const next = !!on;
if (next === _venueActive) {
// Re-activation (e.g. viz:renderer:ready after a late plugin
// load): don't restart the loop, but do retry renderer binding.
if (next && _manifest) bindVideosToRenderer();
return;
}
_venueActive = next;
if (_venueActive && _manifest) start();
else stop();
}
function setManifest(m) {
const norm = normalizeManifest(m);
_manifest = norm;
if (_venueActive) {
// Full stop first even when replacing pack-for-pack: it bumps
// _stopGen so an in-flight load from the OLD manifest can't
// settle and fade a stale URL in after the new pack starts.
stop();
if (norm) start();
}
}
function readDevManifest() {
try {
const raw = localStorage.getItem(DEV_FLAG_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function bindRuntime() {
if (_bound) return;
_bound = true;
const sm = root && root.feedBack;
if (sm && typeof sm.on === 'function') {
sm.on('v3:live-performance-state', onPerformanceState);
sm.on('stats:recorded', onStatsRecorded);
// A new song must not inherit the previous song's crowd mood
// through the hysteresis/dwell window.
sm.on('song:loaded', onSongLoaded);
sm.on('song:play', onSongPlay);
}
const dev = readDevManifest();
if (dev && !_manifest) setManifest(dev);
}
function getState() {
return {
venueActive: _venueActive,
hasManifest: !!_manifest,
crowdState: machine.current,
activeLayer: _activeLayer,
mix: _mix,
stingerActive: _stingerUntilEnded,
introActive: _introActive,
};
}
const api = {
CROWD_STATES,
STABLE_MS,
DWELL_MS,
crowdStateOfPerf,
createCrowdMachine,
stingerForStreak,
stingerForAccuracy,
normalizeManifest,
setManifest,
setVenueActive,
bindRuntime,
getState,
};
if (root) root.v3VenueCrowd = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
// Same defer/DOMContentLoaded dance as venue-scene-3d.js.
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', bindRuntime);
} else {
bindRuntime();
}
}
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));
+13
View File
@@ -96,6 +96,7 @@
if (_active) {
syncInstrumentPov();
syncVenueMotion();
syncCrowd(true);
return;
}
_active = true;
@@ -105,6 +106,17 @@
setH3dMood(_lastMood);
syncInstrumentPov();
syncVenueMotion();
syncCrowd(true);
}
function syncCrowd(on) {
// Reactive crowd video layer (career mode) — inert without a pack.
try {
if (root && root.v3VenueCrowd &&
typeof root.v3VenueCrowd.setVenueActive === 'function') {
root.v3VenueCrowd.setVenueActive(!!on);
}
} catch (_) { /* visual-only */ }
}
function syncVenueMotion() {
@@ -128,6 +140,7 @@
_assetsLoaded = false;
_loadFailed = false;
setH3dActive(false);
syncCrowd(false);
syncPlaceholderVisibility();
}
+60
View File
@@ -1,6 +1,8 @@
"""Shared pytest fixtures for the feedBack test suite."""
import importlib
import logging
import sys
import pytest
import structlog
@@ -76,3 +78,61 @@ def isolate_logging():
lg.setLevel(original_level)
lg.propagate = original_propagate
structlog.reset_defaults()
# ── Plugin-loader isolation ─────────────────────────────────────────────────────
#
# Lifted verbatim out of tests/test_plugins.py so more than one test module can drive
# the real plugins.load_plugins(). It has to be ONE fixture, not a copy per file:
# load_plugins() mutates sys.path, sys.modules, PENDING_PLUGINS and LOADED_PLUGINS, and a
# partial restore makes the suite order- and environment-dependent (Codex [P2] on
# test_plugin_context_contract.py — it was right).
# Bare module names that this test module pre-populates into
# sys.modules to simulate the bare-import path. Saved/restored by
# the reset_plugin_state fixture so they don't leak to other test
# files. Codex / Copilot review on PR for feedBack#33.
_BARE_NAMES_USED = ("util", "extractor")
@pytest.fixture()
def reset_plugin_state(monkeypatch):
"""Clear loader module-level state and restore on teardown.
Saves and restores:
* `plugins.LOADED_PLUGINS`
* any `plugin_*` keys we add to `sys.modules`
* the bare names this module simulates (`util`, `extractor`)
* `sys.path` `plugins.load_plugins()` mutates it
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
(via monkeypatch) so a CI env that pre-sets it can't leak
real user plugins into a tmp_path-driven test. Per-module
locks are owned by the standard import system
(`importlib._bootstrap._module_locks`) and are not our
responsibility to reset.
"""
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
plugins = importlib.import_module("plugins")
saved_loaded = list(plugins.LOADED_PLUGINS)
saved_pending = dict(plugins.PENDING_PLUGINS)
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
saved_path = list(sys.path)
plugins.LOADED_PLUGINS.clear()
plugins.PENDING_PLUGINS.clear()
for k in list(sys.modules):
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
del sys.modules[k]
try:
yield plugins
finally:
plugins.LOADED_PLUGINS.clear()
plugins.LOADED_PLUGINS.extend(saved_loaded)
plugins.PENDING_PLUGINS.clear()
plugins.PENDING_PLUGINS.update(saved_pending)
for k in list(sys.modules):
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
del sys.modules[k]
sys.modules.update(saved_modules)
sys.modules.update(saved_bare)
sys.path[:] = saved_path
+7 -1
View File
@@ -14,10 +14,16 @@ const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// _autoplayExitEnabled was carved out into static/js/player-controls.js (R3a); the
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
// stayed in app.js.
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
// the module is ESM; these sandboxes evaluate plain script text
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
function runEnabled(stored) {
const fnSrc = extractFunction(SRC, 'function _autoplayExitEnabled(');
const fnSrc = extractFunction(CONTROLS_SRC, 'function _autoplayExitEnabled(');
const sandbox = {
localStorage: {
getItem: () => {
+3 -1
View File
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// The highway string-colour manager was carved out of app.js into its own
// module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
+4 -2
View File
@@ -13,7 +13,9 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// The highway string-colour manager was carved out of app.js into its own
// module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
function extractBlock(src, signature) {
@@ -86,7 +88,7 @@ test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
});
// ── Core color manager (static/app.js) ────────────────────────────────────
// ── Core color manager (static/js/highway-colors.js) ──────────────────────
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
const src = fs.readFileSync(appJs, 'utf8');
+113
View File
@@ -0,0 +1,113 @@
// The host-seam contract: the hooks the modules USE must be exactly the hooks
// app.js WIRES.
//
// This is the test that makes the seam safe. static/js/host.js already throws at
// runtime when an unwired hook is read — but a runtime throw only fires if the
// broken path actually executes, and the entire danger of a host seam is the paths
// that DON'T run in a smoke test. That is not hypothetical: the plugin loader's
// seam defaulted a hook to `() => {}`, and a dropped wiring line would have left
// the viz picker silently not refreshing with no test, boot check, or bot noticing.
//
// So this closes it statically. Rename a hook in app.js, drop a line from the
// configureHost({…}) call, or typo a `host.foo` in a module, and CI fails — on a
// path nobody ever ran.
//
// It is deliberately symmetric:
// * used but not wired -> a latent crash (host.js would throw at runtime)
// * wired but not used -> dead weight, and usually the fossil of a rename
// Both fail.
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 APP_JS = path.join(ROOT, 'static', 'app.js');
const JS_DIR = path.join(ROOT, 'static', 'js');
// Strip comments, so prose about `host.foo` in a header block is not read as a call
// site.
//
// NOTHING ELSE. An earlier version also tried to strip import statements (to stop
// `from './host.js'` reading as a hook called `js`) and its `[\s\S]*?` spanned lines
// and silently ate 14,000 characters of the file — including, in the bite test, the
// very drift it was supposed to catch. A guard with a hole in it is worse than no
// guard, because you trust it. The `host.js` path is excluded far more cheaply,
// below, by refusing a match followed by a quote.
function scrub(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/[^\n]*$/gm, '');
}
// `host.<name>` — but not `host.js'` from the `from './host.js'` import path, which is
// the one string in these files that looks like a hook and isn't.
//
// The trailing class must forbid a WORD character as well as a quote. With only
// `(?!['"])`, `host.js'` fails on `js` (a quote follows), then BACKTRACKS to `j` —
// where the next char is `s`, not a quote — and happily reports a hook called `j`.
// Forbidding `[\w$]` too leaves it nowhere to backtrack to.
const HOOK_RE = /(?<![\w$.])host\.([A-Za-z_$][\w$]*)(?![\w$'"])/g;
/** Every `host.<name>` referenced by a carved module. */
function hooksUsed() {
const used = new Map(); // name -> [files]
for (const file of fs.readdirSync(JS_DIR)) {
if (!file.endsWith('.js') || file === 'host.js') continue;
const raw = fs.readFileSync(path.join(JS_DIR, file), 'utf8');
if (!/from\s+'\.\/host\.js'/.test(raw)) continue;
for (const m of scrub(raw).matchAll(HOOK_RE)) {
if (!used.has(m[1])) used.set(m[1], []);
used.get(m[1]).push(file);
}
}
return used;
}
/** Every hook app.js passes to configureHost({ … }). */
function hooksWired() {
const src = scrub(fs.readFileSync(APP_JS, 'utf8'));
// NB the closing brace is INDENTED (the call sits inside the boot function), so
// anchoring on `\n});` at column 0 runs straight past it and swallows the next
// object literal in the file — which is how this first read 77 "hooks", most of
// them app.js's window contract.
const call = src.match(/configureHost\(\{([\s\S]*?)\n\s*\}\);/);
if (!call) return null; // no seam wired yet — fine until there is one
const wired = new Set();
for (const m of call[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*(?=[,:}]|$)/gm)) {
wired.add(m[1]);
}
return wired;
}
test('every host.<hook> a module uses is wired by app.js', () => {
const used = hooksUsed();
if (used.size === 0) return; // no consumers yet
const wired = hooksWired();
assert.ok(wired, 'modules import ./host.js but app.js never calls configureHost({ … })');
const missing = [...used.keys()]
.filter((h) => !wired.has(h))
.map((h) => `${h} (used in ${used.get(h).join(', ')})`);
assert.deepEqual(
missing, [],
'these hooks are read by a module but never wired by app.js — they would throw at runtime, '
+ 'on whatever path happens to reach them',
);
});
test('every hook app.js wires is actually used by a module', () => {
const wired = hooksWired();
if (!wired || wired.size === 0) return;
const used = hooksUsed();
const unused = [...wired].filter((h) => !used.has(h));
assert.deepEqual(
unused, [],
'these hooks are wired by app.js but no module reads them — dead weight, and usually '
+ 'the fossil of a rename that left the other half behind',
);
});
+20 -4
View File
@@ -1,4 +1,4 @@
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
// Behavioral tests for the JUCE engine-reroute watcher in static/js/juce-audio.js.
//
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
// song between the HTML5 <audio> element and the native JUCE backing transport
@@ -14,14 +14,15 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The JUCE audio shims were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
// and ending after the matching `})();`.
function extractWatcherIIFE(src) {
const marker = '(function _installJuceEngineRoutingWatcher() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
assert.ok(start !== -1, 'watcher IIFE not found in static/js/juce-audio.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
@@ -84,7 +85,11 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
json: () => Promise.resolve({ path: '/local/song.ogg' }),
}),
document: { hidden: false },
isPlaying: true,
// `isPlaying` moved onto the shared player-state container so a carved module
// can WRITE it (an imported binding is read-only). The sliced code now reads and
// writes S.isPlaying, so the sandbox provides the same container — the
// assertions below are unchanged.
S: { isPlaying: true, lastAudioTime: 0 },
audio,
jucePlayer,
__calls: calls,
@@ -96,6 +101,17 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
const src = fs.readFileSync(APP_JS, 'utf8');
const iife = extractWatcherIIFE(src);
// The shims reach back into app.js through the host seam (static/js/host.js).
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
// swallow the calls and the assertions below would pass vacuously.
sandbox.host = {
jucePlayer: () => sandbox.jucePlayer,
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
};
vm.createContext(sandbox);
vm.runInContext(iife, sandbox);
return sandbox;
+12 -3
View File
@@ -77,6 +77,11 @@ const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
// The viz layer was carved out of app.js too (R3a).
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
// The library itself was carved out of app.js into ./static/js/library.js (R3a). Note the
// two are DIFFERENT files: LIBRARY_JS above is the capability; this is the UI module.
// syncLibrarySong deliberately stayed behind in app.js — it reaches showScreen/playSong,
// and moving it would have dragged the whole playback core into the library module.
const LIBRARY_MODULE_JS = path.join(ROOT, 'static', 'js', 'library.js');
function source(file) {
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
@@ -93,16 +98,20 @@ function region(src, needle, length = 1200) {
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
const src = source(PLUGIN_LOADER_JS);
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
// Anchored on the ASSIGNMENT, not the URL literal: the URL is built in
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL for the whole
// import graph), so the old literal no longer appears at the injection site.
const block = region(src, 'script.src = _pluginScriptUrl(');
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
});
test('library providers route through native library capability', () => {
const src = source(APP_JS);
const libModule = source(LIBRARY_MODULE_JS);
const librarySrc = source(LIBRARY_JS);
const loader = region(src, 'async function loadLibraryProviders', 1800);
const selector = region(src, 'async function setLibraryProvider(providerId, options = {})', 1600);
const loader = region(libModule, 'async function loadLibraryProviders', 1800);
const selector = region(libModule, 'async function setLibraryProvider(providerId, options = {})', 1600);
const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600);
assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/);
+44 -12
View File
@@ -11,11 +11,16 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// The A-B loop was carved out of app.js into its own module (R3a). The
// window.feedBack API surface it is published through stayed in app.js.
const LOOPS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'loops.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
function extractFunction(rawSrc, signature) {
// loops.js is an ES module; the vm sandbox evaluates plain script text.
const src = rawSrc.replace(/^export /gm, '');
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in static/js/loops.js`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
@@ -44,10 +49,18 @@ function buildSandbox() {
const seekCalls = [];
const sectionPracticeModeCalls = [];
const transportEvents = [];
// clearLoop() used to zero section-practice's three selection scalars by hand.
// They now live in static/js/section-practice.js, which owns them, so clearLoop
// calls its exported resetSelection() instead. This is a SPY, not a stub — the
// test below still asserts the reset happens, it just asserts it through the
// seam rather than by reaching into someone else's state.
const resetSelectionCalls = [];
const sandbox = {
seekCalls,
sectionPracticeModeCalls,
transportEvents,
resetSelectionCalls,
resetSelection: () => resetSelectionCalls.push(true),
// Mutable state (declared as `var` in eval prelude so it lives on
// the sandbox global and the extracted functions can read/write).
// The actual values are set below.
@@ -81,6 +94,7 @@ function buildSandbox() {
// updateLoopUI references formatTime for the label; we don't
// assert on the label text in these tests, so a stub is enough.
formatTime: (s) => String(s),
_updateEditRegionBtn: () => {},
window: {
feedBack: {
playback: {
@@ -89,6 +103,19 @@ function buildSandbox() {
},
},
};
// The loop module reaches back into app.js through the host seam
// (static/js/host.js), so the extracted bodies call host._audioSeek(),
// host._audioTime(), and so on. Point the seam at the SAME spies the sandbox
// already had: the assertions below are unchanged, they just travel through the
// indirection the real code now uses.
sandbox.host = {
_audioSeek: (...a) => sandbox._audioSeek(...a),
_audioTime: () => sandbox._audioTime(),
formatTime: (...a) => sandbox.formatTime(...a),
_updateEditRegionBtn: () => sandbox._updateEditRegionBtn(),
currentFilename: () => 'test-song.sloppak',
startCountIn: () => {},
};
vm.createContext(sandbox);
return sandbox;
}
@@ -121,7 +148,7 @@ function loadFunctions(sandbox, src) {
}
test('setLoop mutates loopA/loopB and seeks to A', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -137,7 +164,7 @@ test('setLoop mutates loopA/loopB and seeks to A', async () => {
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
// false; the loop is NOT armed.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
loadFunctions(sandbox, src);
@@ -154,7 +181,7 @@ test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek',
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
// from the requested a. The loop is NOT armed.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
loadFunctions(sandbox, src);
@@ -172,7 +199,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
// values may already be strings. Number() coercion in setLoop must
// accept finite numeric strings.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -183,7 +210,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
});
test('setLoop rejects non-finite inputs', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -193,7 +220,7 @@ test('setLoop rejects non-finite inputs', async () => {
});
test('setLoop rejects b <= a', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -201,8 +228,8 @@ test('setLoop rejects b <= a', async () => {
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
});
test('clearLoop resets loopA/loopB to null', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => {
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -211,6 +238,11 @@ test('clearLoop resets loopA/loopB to null', async () => {
const { loopA, loopB } = sandbox.__getLoop();
assert.equal(loopA, null);
assert.equal(loopB, null);
assert.equal(
sandbox.resetSelectionCalls.length, 1,
'clearLoop must ask section-practice to drop its selection (it used to zero the '
+ 'scalars by hand; the module owns them now)',
);
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
// Field-wise: vm-context objects break deepStrictEqual across realms.
@@ -218,7 +250,7 @@ test('clearLoop resets loopA/loopB to null', async () => {
});
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -256,7 +288,7 @@ test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () =>
// re-implementing the loopA/loopB assignment. Catches a future drift
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
// sync.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const fn = extractFunction(src, 'async function loadSavedLoop(');
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
// The pre-refactor body assigned loopA = parseFloat(...) directly;
+23 -11
View File
@@ -14,7 +14,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// startCountIn was carved out of app.js into its own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
// and brace-matching to the closing brace. Skips an optional `( ... )` param
@@ -55,8 +56,10 @@ function buildSandbox() {
loopA: 10,
loopB: 20,
_countingIn: false,
isPlaying: false,
lastAudioTime: 0,
// isPlaying / lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
// Browser-ish globals.
performance: { now: () => Date.now() },
@@ -109,12 +112,23 @@ function buildSandbox() {
__emitCalls: emitCalls,
queueMicrotask,
};
// startCountIn was carved into static/js/count-in.js and now reaches back into
// app.js through the host seam (static/js/host.js). Point the seam at the SAME
// stubs the sandbox already had: the assertions below are unchanged, they just
// travel through the indirection the real code now uses.
sandbox.host = {
_audioSeek: (...a) => sandbox._audioSeek(...a),
setPlayButtonState: () => {},
_songEventPayload: () => ({}),
togglePlay: () => {},
jucePlayer: () => sandbox.jucePlayer,
};
vm.createContext(sandbox);
return sandbox;
}
test('loop:restart fires once when wrap path runs', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
// Sanity check: the change under test is present at all. Catches
@@ -135,8 +149,7 @@ test('loop:restart fires once when wrap path runs', async () => {
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var isPlaying = false;
var lastAudioTime = 0;
var S = { isPlaying: false, lastAudioTime: 0 };
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
`;
@@ -166,7 +179,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
// _audioSeek resolves with completed:true but r.to !== loopA. The
// wrap handler must abort instead of running beginCount on the wrong
// position and emitting a misleading loop:restart.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
const sandbox = buildSandbox();
@@ -180,8 +193,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var isPlaying = false;
var lastAudioTime = 0;
var S = { isPlaying: false, lastAudioTime: 0 };
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
globalThis.__getCountingIn = () => _countingIn;
@@ -202,7 +214,7 @@ test('count-in cancellation token bails delayed callbacks (rewindStep + tick)',
// teardown can interrupt an in-flight count-in. Behavioral simulation
// of timer cancellation is out of scope for the static extractor; this
// verifies the contract is wired into the source.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const fn = extractFunction(src, 'async function startCountIn');
// Captures gen at entry
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
@@ -218,7 +230,7 @@ test('loop:restart fires after highway.setTime, before beginCount', () => {
// Source-order assertion on the A-B wrap path only. Section-practice
// `opts.immediate` also emits loop:restart but is a separate entry path;
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const fn = extractFunction(src, 'async function startCountIn');
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
const wrapStart = fn.indexOf(wrapMarker);
+7 -4
View File
@@ -18,7 +18,7 @@ const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
const TOGGLE_PLAY_SRC = extractFunction(SRC, 'async function togglePlay(');
@@ -29,8 +29,11 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
const buttonStates = [];
const sandbox = {
console: { log() {}, warn() {}, error() {} },
// not-playing -> togglePlay takes the HTML5 play branch
isPlaying: false,
// not-playing -> togglePlay takes the HTML5 play branch.
// isPlaying / lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
_audioSeekGen: 0,
_playAttemptGen: 0,
setPlayButtonState(v) { buttonStates.push(v); },
@@ -51,7 +54,7 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
vm.createContext(sandbox);
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
await vm.runInContext('togglePlay()', sandbox);
return { buttonStates, isPlaying: sandbox.isPlaying };
return { buttonStates, isPlaying: sandbox.S.isPlaying };
}
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
+10 -3
View File
@@ -5,6 +5,10 @@ const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// SPLIT. _installPlaybackTransportAdapter stayed in app.js — it reads loopA/loopB from
// ./js/loops.js, and loops.js imports transport, so moving it would close a cycle.
// _waitForSongReady went with the rest of the seek machinery.
const TRANSPORT_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -54,7 +58,7 @@ function loadReadyHelper(sandbox, src) {
}
test('_waitForSongReady rejects a ready event from a different audio generation', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(TRANSPORT_JS, 'utf8');
const sandbox = buildReadySandbox();
loadReadyHelper(sandbox, src);
@@ -72,7 +76,7 @@ test('playback adapter scopes startTime readiness and validates seek targets', (
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
assert.match(fn, /const expectedSeekGen\s*=\s*_audioSeekGen\s*\+\s*1;/);
assert.match(fn, /const expectedSeekGen\s*=\s*audioSeekGen\(\)\s*\+\s*1;/);
assert.match(fn, /_waitForSongReady\(expectedSeekGen\)/);
assert.match(fn, /const seconds\s*=\s*Number\(time\);/);
assert.match(fn, /!Number\.isFinite\(seconds\)\s*\|\|\s*seconds\s*<\s*0/);
@@ -84,5 +88,8 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
// isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). window.feedBack.isPlaying — the
// public mirror — is unchanged.
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*S\.isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
});
+12 -6
View File
@@ -19,13 +19,19 @@ const path = require('node:path');
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
// Isolate the screen.js <script> injection block: from where its src is built
// to where the element is appended.
// Isolate the screen.js <script> injection block: from where its src is assigned to
// where the element is appended.
//
// Anchored on the ASSIGNMENT, not on the URL literal. The URL is built in
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL), so the literal
// '/api/plugins/${plugin.id}/screen.js' appears FURTHER DOWN the file than the block
// that uses it, and slicing from it ran off the end of the injection block entirely.
const SRC_ASSIGN = 'script.src = _pluginScriptUrl(';
function injectionBlock() {
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
const start = src.indexOf(SRC_ASSIGN);
assert.ok(start !== -1, 'screen.js src assignment not found — loader moved?');
const end = src.indexOf('document.body.appendChild(script)', start);
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
assert.ok(end !== -1, 'appendChild(script) not found after the src assignment');
return src.slice(start, end);
}
@@ -52,7 +58,7 @@ test('the module type is gated, never set unconditionally', () => {
test('the module guard sits before appendChild, after the src assignment', () => {
const guardAt = src.indexOf('script.type = \'module\'');
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
const srcAt = src.indexOf(SRC_ASSIGN);
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
assert.ok(guardAt > srcAt && guardAt < appendAt,
'the module guard must live inside the screen.js injection block');
+83
View File
@@ -0,0 +1,83 @@
// #879 — a plugin ROLLBACK must actually re-evaluate a module plugin.
//
// ES modules are evaluated once per URL per document. Re-inserting a
// <script type="module"> whose src the module map has already seen fires `load` but
// does NOT re-run the body — so rolling back to a version already evaluated this
// session left the OLD module live while the loader recorded success.
//
// The fix puts a generation token in the PATH (/api/plugins/x/g/7/screen.js), not the
// query, because a relative specifier resolves against the base URL with the query
// DROPPED — so './src/main.js' would otherwise keep resolving to the same cached URL
// and the plugin's actual code would never re-run.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const LOADER = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
function makeUrlBuilder() {
const src = fs.readFileSync(LOADER, 'utf8');
const sandbox = { _evaluatedModules: new Set(), _moduleReloadSeq: 0 };
vm.createContext(sandbox);
vm.runInContext(`
${extractFunction(src, 'function _pluginScriptUrl(')}
globalThis.url = _pluginScriptUrl;
`, sandbox);
return sandbox.url;
}
const MOD = { id: 'editor', script_type: 'module' };
const CLASSIC = { id: 'legacy', script_type: 'classic' };
test('a module plugin first load uses the stable ?v= URL (ETag/304 stays intact)', () => {
const url = makeUrlBuilder();
assert.equal(url(MOD, '1.0.0', '?v=1.0.0'), '/api/plugins/editor/screen.js?v=1.0.0');
});
// An UPGRADE has to bust the graph too, and this is the part #879 got wrong. It says
// "upgrades are fine — a new version yields a new URL". True of screen.js; FALSE of the
// plugin. Driving a real browser through install -> upgrade -> rollback and counting
// evaluations of src/main.js gives ONE: the upgrade re-runs the one-line screen.js shim
// at its new ?v= URL, the shim imports './src/main.js', that resolves to the SAME url,
// and the module map hands back the already-evaluated old module. So the key here is the
// plugin ID, not id@version — every re-load of a module plugin needs a fresh path.
test('an UPGRADE also gets a fresh /g/<n>/ path — a new ?v= does NOT reach the graph', () => {
const url = makeUrlBuilder();
url(MOD, '1.0.0', '?v=1.0.0');
assert.equal(url(MOD, '1.1.0', '?v=1.1.0'), '/api/plugins/editor/g/1/screen.js?v=1.1.0');
});
test('a ROLLBACK to an already-evaluated version gets a fresh /g/<n>/ PATH', () => {
const url = makeUrlBuilder();
url(MOD, '1.0.0', '?v=1.0.0'); // installed
url(MOD, '1.1.0', '?v=1.1.0'); // upgraded -> /g/1/
const back = url(MOD, '1.0.0', '?v=1.0.0'); // rolled back -> /g/2/
assert.equal(back, '/api/plugins/editor/g/2/screen.js?v=1.0.0');
// The token must be in the PATH so a relative import INHERITS it — the whole point.
// A query token is dropped by URL resolution and never reaches src/main.js.
const resolved = new URL('./src/main.js', `http://h${back}`).pathname;
assert.equal(resolved, '/api/plugins/editor/g/2/src/main.js',
'the token must reach the module GRAPH, not just the entry point');
});
test('every re-load gets a distinct URL (no reuse across a bounce)', () => {
const url = makeUrlBuilder();
url(MOD, '1.0.0', '?v=1.0.0');
const seen = new Set();
for (const v of ['1.1.0', '1.0.0', '1.1.0', '1.0.0']) seen.add(url(MOD, v, `?v=${v}`));
assert.equal(seen.size, 4, 'each re-load must be a URL the module map has never seen');
});
test('classic-script plugins are untouched — they always re-run on re-insert', () => {
const url = makeUrlBuilder();
const first = url(CLASSIC, '1.0.0', '?v=1.0.0');
url(CLASSIC, '1.1.0', '?v=1.1.0');
const back = url(CLASSIC, '1.0.0', '?v=1.0.0');
assert.equal(first, '/api/plugins/legacy/screen.js?v=1.0.0');
assert.equal(back, first, 'a classic script needs no cache-busting and must not get a /g/ path');
});
+15 -3
View File
@@ -1,4 +1,4 @@
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
// Behavioral tests for the renderer-audio bus feeder in static/js/juce-audio.js.
//
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
@@ -16,12 +16,13 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The JUCE audio shims were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
function extractFeederIIFE(src) {
const marker = '(function _installRendererBusFeeder() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
assert.ok(start !== -1, 'feeder IIFE not found in static/js/juce-audio.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
@@ -123,6 +124,17 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, disp
sandbox.globalThis = sandbox;
const src = fs.readFileSync(APP_JS, 'utf8');
// The shims reach back into app.js through the host seam (static/js/host.js).
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
// swallow the calls and the assertions below would pass vacuously.
sandbox.host = {
jucePlayer: () => sandbox.jucePlayer,
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
};
vm.createContext(sandbox);
vm.runInContext(extractFeederIIFE(src), sandbox);
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
+3 -2
View File
@@ -15,9 +15,10 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
// _installSectionPracticeDismiss was carved out of app.js into its own module (R3a).
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'section-practice.js'), 'utf8');
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js');
const body = m[0];
test('the outside-click dismiss binds in the CAPTURE phase', () => {
+3 -2
View File
@@ -14,8 +14,9 @@ const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
// the song-credits overlay was carved out of app.js into its own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
const SRC = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
// Minimal fake DOM element: records className, children, and textContent.
// Setting textContent clears children (matching real DOM) so we can assert
+15 -2
View File
@@ -12,7 +12,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -129,11 +129,24 @@ test('every song:play/pause/ended emit uses _songEventPayload', () => {
);
});
// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
// carve keeps moving them between app.js and static/js/*.js — point it at a single file
// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
function allFrontendSources() {
const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
return parts.join('\n');
}
test('there are at least 8 song:* emit sites threaded through the helper', () => {
// Sanity-check that the helper actually got wired everywhere. If the
// count drops, someone removed an emit (regression) or refactored an
// event away (intentional — this test then needs updating).
const src = fs.readFileSync(APP_JS, 'utf8');
const src = allFrontendSources();
const matches = src.match(/(?:window\.feedBack|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
assert.ok(
matches.length >= 8,
+5 -3
View File
@@ -19,7 +19,9 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
const sandbox = {
loopA,
loopB,
isPlaying,
// isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). Same value, same assertions.
S: { isPlaying, lastAudioTime: 0 },
__cancelCountInCalls: 0,
__seekCalls: [],
__startCountInCalls: [],
@@ -42,7 +44,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
},
__togglePlay() {
sandbox.__togglePlayCalls++;
sandbox.isPlaying = true;
sandbox.S.isPlaying = true;
return Promise.resolve();
},
};
@@ -53,7 +55,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
const code = `
var isPlaying = ${sandbox.isPlaying};
var S = { isPlaying: ${sandbox.S.isPlaying}, lastAudioTime: 0 };
function _cancelCountIn() { __cancelCountInCalls++; }
async function _audioSeek(s, reason) {
return (${audioSeekImpl || '__audioSeek'})(s, reason);
+20 -4
View File
@@ -1,4 +1,4 @@
// Verify static/app.js emits `song:seek` for every audio repositioning,
// Verify static/js/transport.js emits `song:seek` for every audio repositioning,
// with `{ from, to, reason }` payload. Plugins (notedetect detection-
// suppression during seek transients) consume this contract.
//
@@ -11,7 +11,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -77,7 +77,10 @@ function loadFunctions(sandbox, src) {
// _audioSeek now syncs the jump-fix tracker so far seeks don't
// trigger an immediate revert; declare it here so the sandbox
// assignment lands on a real binding rather than an implicit global.
let lastAudioTime = 0;
// lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE it — an imported
// binding is read-only. The sliced code writes S.lastAudioTime now.
let S = { isPlaying: false, lastAudioTime: 0 };
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
// helper + constant. Tests can override jucePlayer.seek to vary
// behavior; the timeout (2 s) is well above any test setTimeout.
@@ -284,13 +287,26 @@ test('seekBy floors at zero (does not seek to negative time)', async () => {
assert.equal(seek.detail.to, 0);
});
// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
// carve keeps moving them between app.js and static/js/*.js — point it at a single file
// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
function allFrontendSources() {
const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
for (const f of fs.readdirSync(jsDir).sort()) {
if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
}
return parts.join('\n');
}
test('every documented seek callsite passes a reason', () => {
// Source-order assertion: every _audioSeek call outside the
// implementation must pass a kebab-case reason string. Catches a
// future contributor adding a new seek path without threading the
// reason. Line-based — regex argument capture can't balance parens
// through Math.max/_audioTime calls.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = allFrontendSources();
const fnSrc = extractFunction(src, 'async function _audioSeek(');
const withoutImpl = src.replace(fnSrc, '');
const callLines = withoutImpl.split('\n').filter((l) => /_audioSeek\(/.test(l));
+22 -9
View File
@@ -5,6 +5,9 @@ const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The speed controls were carved out into static/js/player-controls.js (R3a); playSong,
// which resets them on a new song, stayed in app.js. This test spans both.
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -130,20 +133,30 @@ function extractConstLine(src, name) {
function loadPlaySong(sandbox) {
const src = fs.readFileSync(APP_JS, 'utf8');
const resetHelper = src.includes('function _resetPlaybackSpeedForNewSong')
? extractFunction(src, 'function _resetPlaybackSpeedForNewSong')
// the module is ESM; the vm sandbox evaluates plain script text
const controls = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
const resetHelper = controls.includes('function _resetPlaybackSpeedForNewSong')
? extractFunction(controls, 'function _resetPlaybackSpeedForNewSong')
: '';
const speedPresetHelpers = src.includes('function _updateSpeedPresetButtons')
const speedPresetHelpers = controls.includes('function _updateSpeedPresetButtons')
? `
${extractConstLine(src, 'SPEED_PRESET_PCTS')}
${extractConstLine(src, 'SPEED_SNAP_THRESHOLD')}
${extractFunction(src, 'function _speedPresetPctFromActive')}
${extractFunction(src, 'function _updateSpeedPresetButtons')}
${extractConstLine(controls, 'SPEED_PRESET_PCTS')}
${extractConstLine(controls, 'SPEED_SNAP_THRESHOLD')}
${extractFunction(controls, 'function _speedPresetPctFromActive')}
${extractFunction(controls, 'function _updateSpeedPresetButtons')}
`
: '';
const code = `
var artAbortController = null;
var isPlaying = true;
// isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
// public mirror stubbed above — is a different thing and is unchanged.
var S = { isPlaying: true, lastAudioTime: 0 };
// The speed controls reach app.js through the host seam (static/js/host.js).
// Route it at the sandbox's EXISTING handleSliderInput spy — a fresh stub would
// swallow the call and the assertion below (which checks the slider was actually
// refreshed) would pass vacuously.
var host = { handleSliderInput: (el) => handleSliderInput(el) };
var currentFilename = null;
var _playerOriginScreen = null;
var _pendingAutostart = false;
@@ -163,7 +176,7 @@ function loadPlaySong(sandbox) {
function _scheduleSectionPracticeRetries() {}
function loadSavedLoops() {}
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
${extractFunction(src, 'function setSpeed')}
${extractFunction(controls, 'function setSpeed')}
${speedPresetHelpers}
${resetHelper}
${extractFunction(src, 'async function playSong')}
+9 -6
View File
@@ -7,20 +7,23 @@ const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a);
// the autoplay-gate test below still reads app.js.
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
const src = fs.readFileSync(TUNING_JS, 'utf8');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length),
body,
sandbox
);
return sandbox.window.feedBack;
+2 -1
View File
@@ -6,7 +6,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
+7 -6
View File
@@ -6,7 +6,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
@@ -14,14 +15,14 @@ const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length) + '\n'
body + '\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
+ 'exports.isBassArrangement = isBassArrangement;\n'
+5 -1
View File
@@ -16,7 +16,11 @@ const path = require('node:path');
const root = path.join(__dirname, '..', '..');
const SONGS = fs.readFileSync(path.join(root, 'static', 'v3', 'songs.js'), 'utf8');
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8');
// The rescan path moved into ./static/js/library.js with the rest of the library (R3a).
// Read BOTH: this asserts the emit exists SOMEWHERE in the app, and pinning it to one file
// just means the test starts lying the next time the code moves.
const APP = fs.readFileSync(path.join(root, 'static', 'app.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(root, 'static', 'js', 'library.js'), 'utf8');
test('app.js emits library:changed when a Settings rescan completes', () => {
assert.match(APP, /emit\(\s*['"]library:changed['"]/,
+7 -6
View File
@@ -7,7 +7,8 @@ const path = require('node:path');
const vm = require('node:vm');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
function extractBlock(src, startMarker) {
const start = src.indexOf(startMarker);
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length) + '\n'
body + '\n'
+ 'exports.displayTuningName = displayTuningName;\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
+130
View File
@@ -0,0 +1,130 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crowd = require('../../static/v3/venue-crowd.js');
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const SCENE_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'venue-scene-3d.js');
test('perf state → crowd state mapping', () => {
assert.equal(crowd.crowdStateOfPerf('smoke'), 'bored');
assert.equal(crowd.crowdStateOfPerf('recovery'), 'bored');
assert.equal(crowd.crowdStateOfPerf('idle'), 'neutral');
assert.equal(crowd.crowdStateOfPerf('steady'), 'neutral');
assert.equal(crowd.crowdStateOfPerf('strong'), 'engaged');
assert.equal(crowd.crowdStateOfPerf('fire'), 'ecstatic');
assert.equal(crowd.crowdStateOfPerf('FIRE'), 'ecstatic');
assert.equal(crowd.crowdStateOfPerf('bogus'), 'neutral');
assert.equal(crowd.crowdStateOfPerf(undefined), 'neutral');
});
test('machine: target must be stable for STABLE_MS before committing', () => {
const m = crowd.createCrowdMachine();
assert.equal(m.current, 'neutral');
assert.equal(m.update('fire', 0), null);
assert.equal(m.update('fire', crowd.STABLE_MS - 1), null);
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic');
assert.equal(m.current, 'ecstatic');
});
test('machine: flapping target restarts the stability window', () => {
const m = crowd.createCrowdMachine();
m.update('fire', 0);
// Target changes → candidate resets.
m.update('strong', 1000);
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS - 1), null);
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS), 'engaged');
});
test('machine: returning to current state clears the candidate', () => {
const m = crowd.createCrowdMachine();
m.update('fire', 0);
m.update('steady', 1000); // back to neutral (current) — candidate dropped
// 'fire' again must wait a full stability window from scratch.
assert.equal(m.update('fire', 2000), null);
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS - 1), null);
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS), 'ecstatic');
});
test('machine: DWELL_MS enforced between switches', () => {
const m = crowd.createCrowdMachine();
m.update('fire', 0);
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic'); // switch at t=3000
const t = crowd.STABLE_MS;
// Immediately drop to smoke: stable window passes but dwell hasn't.
m.update('smoke', t + 1);
assert.equal(m.update('smoke', t + 1 + crowd.STABLE_MS), null);
// After the dwell expires the pending candidate commits.
assert.equal(m.update('smoke', t + crowd.DWELL_MS), 'bored');
});
test('machine: multi-step jumps allowed (bored → ecstatic)', () => {
const m = crowd.createCrowdMachine();
m.update('smoke', 0);
assert.equal(m.update('smoke', crowd.STABLE_MS), 'bored');
const t = crowd.DWELL_MS + 1000;
m.update('fire', t);
assert.equal(m.update('fire', t + crowd.STABLE_MS), 'ecstatic');
});
test('stingerForStreak fires on rising milestone crossings only', () => {
assert.equal(crowd.stingerForStreak(24, 25), 'cheer');
assert.equal(crowd.stingerForStreak(0, 100), 'cheer');
assert.equal(crowd.stingerForStreak(25, 26), null);
assert.equal(crowd.stingerForStreak(50, 50), null);
assert.equal(crowd.stingerForStreak(30, 0), null); // streak reset
});
test('stingerForAccuracy thresholds', () => {
assert.equal(crowd.stingerForAccuracy(95), 'cheer');
assert.equal(crowd.stingerForAccuracy(90), 'cheer');
assert.equal(crowd.stingerForAccuracy(80), 'clap');
assert.equal(crowd.stingerForAccuracy(75), 'clap');
assert.equal(crowd.stingerForAccuracy(60), null);
assert.equal(crowd.stingerForAccuracy('nope'), null);
assert.equal(crowd.stingerForAccuracy(undefined), null);
});
test('normalizeManifest requires all four loops, resolves base', () => {
assert.equal(crowd.normalizeManifest(null), null);
assert.equal(crowd.normalizeManifest({}), null);
assert.equal(crowd.normalizeManifest({ loops: { bored: 'b.mp4' } }), null);
const m = crowd.normalizeManifest({
base: '/api/plugins/career/venues/bar/',
loops: { bored: 'bored.mp4', neutral: 'neutral.mp4', engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4' },
stingers: { cheer: 'cheer.mp4' },
});
assert.equal(m.loops.ecstatic, '/api/plugins/career/venues/bar/ecstatic.mp4');
assert.equal(m.stingers.cheer, '/api/plugins/career/venues/bar/cheer.mp4');
assert.equal(m.stingers.clap, '');
});
test('highway_3d exposes the crowd backdrop globals', () => {
const src = fs.readFileSync(H3D_JS, 'utf8');
assert.match(src, /window\.h3dVenueBackdropSetVideo\s*=/);
assert.match(src, /window\.h3dVenueBackdropSetMix\s*=/);
// The venue style must own crowd plane teardown (VideoTexture dispose).
assert.match(src, /_venueCrowdVideos/);
assert.match(src, /_venueCrowdMix/);
});
test('index.html loads venue-crowd.js deferred, after venue-scene-3d.js', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
const crowdIdx = html.indexOf('/static/v3/venue-crowd.js');
const sceneIdx = html.indexOf('/static/v3/venue-scene-3d.js');
assert.ok(crowdIdx > 0, 'venue-crowd.js script tag missing');
assert.ok(crowdIdx > sceneIdx, 'venue-crowd.js must load after venue-scene-3d.js');
assert.match(html, /<script defer src="\/static\/v3\/venue-crowd\.js"><\/script>/);
});
test('venue-scene-3d activates/deactivates the crowd layer', () => {
const src = fs.readFileSync(SCENE_JS, 'utf8');
assert.match(src, /syncCrowd\(true\)/);
assert.match(src, /syncCrowd\(false\)/);
assert.match(src, /v3VenueCrowd/);
});
+22 -21
View File
@@ -7,6 +7,7 @@ import os
import sys
import time
import builtin_content
import pytest
@@ -23,13 +24,13 @@ def test_seed_creates_builtin_diagnostic_sloppak(tmp_path, server_mod):
"""First seed copies the bundled sloppak into diagnostics-builtin/."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
if not source.is_file():
pytest.skip(f"source sloppak not present in checkout: {source}")
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
dest = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
dest = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
assert dest.is_file()
assert dest.stat().st_size == source.stat().st_size
@@ -38,16 +39,16 @@ def test_seed_is_idempotent_when_destination_exists(tmp_path, server_mod):
"""Second seed leaves an up-to-date destination unchanged."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
if not source.is_file():
pytest.skip(f"source sloppak not present in checkout: {source}")
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
dest = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
dest = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
first_mtime = dest.stat().st_mtime_ns
first_size = dest.stat().st_size
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
assert dest.stat().st_mtime_ns == first_mtime
assert dest.stat().st_size == first_size
@@ -57,18 +58,18 @@ def test_seed_skips_when_destination_is_newer(tmp_path, server_mod):
"""An existing newer destination is not overwritten."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
if not source.is_file():
pytest.skip(f"source sloppak not present in checkout: {source}")
dest_dir = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR
dest_dir = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR
dest_dir.mkdir(parents=True)
dest_name = server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
dest_name = builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
dest = dest_dir / dest_name
dest.write_bytes(b"user-owned diagnostic copy")
future = time.time() + 3600
os.utime(dest, (future, future))
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
assert dest.read_bytes() == b"user-owned diagnostic copy"
@@ -77,18 +78,18 @@ def test_seed_refuses_to_follow_symlink_destination(tmp_path, server_mod):
"""A symlink at the destination is skipped, not written through."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = server_mod._feedBack_server_root() / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][1]
source = server_mod._feedBack_server_root() / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][1]
if not source.is_file():
pytest.skip(f"source sloppak not present in checkout: {source}")
outside = tmp_path / "outside.txt"
outside.write_bytes(b"do not overwrite me")
dest_dir = dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR
dest_dir = dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR
dest_dir.mkdir(parents=True)
dest = dest_dir / server_mod._BUILTIN_DIAGNOSTIC_SOURCES[0][0]
dest = dest_dir / builtin_content.BUILTIN_DIAGNOSTIC_SOURCES[0][0]
dest.symlink_to(outside)
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
# The symlink target must be untouched and the link left as-is.
assert outside.read_bytes() == b"do not overwrite me"
@@ -101,11 +102,11 @@ def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
dlc.mkdir()
outside_dir = tmp_path / "outside_dir"
outside_dir.mkdir()
(dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR).symlink_to(
(dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR).symlink_to(
outside_dir, target_is_directory=True
)
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
# Nothing was written through the directory symlink into the link target.
assert list(outside_dir.iterdir()) == []
@@ -116,11 +117,11 @@ def test_seed_missing_source_does_not_crash(tmp_path, server_mod, monkeypatch):
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setattr(
server_mod,
"_BUILTIN_DIAGNOSTIC_SOURCES",
builtin_content,
"BUILTIN_DIAGNOSTIC_SOURCES",
[("missing.sloppak", "docs/diagnostics/does-not-exist.sloppak")],
)
server_mod._seed_builtin_diagnostic_sloppaks(dlc)
builtin_content.seed_builtin_diagnostic_sloppaks(server_mod._feedBack_server_root(), dlc)
assert not (dlc / server_mod._BUILTIN_DIAGNOSTIC_SUBDIR / "missing.sloppak").exists()
assert not (dlc / builtin_content.BUILTIN_DIAGNOSTIC_SUBDIR / "missing.sloppak").exists()
+32 -31
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import importlib
import sys
import builtin_content
import pytest
@@ -21,15 +22,15 @@ def server_mod(tmp_path, monkeypatch, isolate_logging):
def _source(server_mod):
return (
server_mod._feedBack_server_root()
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
/ builtin_content.BUILTIN_STARTER_SOURCES[0][1]
)
def _dest(server_mod, dlc):
return (
dlc
/ server_mod._BUILTIN_STARTER_SUBDIR
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
/ builtin_content.BUILTIN_STARTER_SUBDIR
/ builtin_content.BUILTIN_STARTER_SOURCES[0][0]
)
@@ -41,12 +42,12 @@ def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
assert dest.stat().st_size == source.stat().st_size
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
def test_seed_preserves_source_mtime(tmp_path, server_mod):
@@ -58,7 +59,7 @@ def test_seed_preserves_source_mtime(tmp_path, server_mod):
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
@@ -78,7 +79,7 @@ def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
@@ -86,7 +87,7 @@ def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
dest.unlink()
# A subsequent launch must not re-seed it.
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert not dest.exists()
@@ -98,15 +99,15 @@ def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
pytest.skip(f"starter source not present in checkout: {source}")
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
server_mod._seed_builtin_starter_content(None)
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), None)
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
# Now a DLC is configured: the deferred seed runs.
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert _dest(server_mod, dlc).is_file()
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
@@ -119,13 +120,13 @@ def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
(dlc / builtin_content.BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert list(outside_dir.iterdir()) == []
# An incomplete seed must NOT write the marker, so a later launch retries.
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
@@ -140,11 +141,11 @@ def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
dest.write_bytes(b"user's own edited pack")
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert dest.read_bytes() == b"user's own edited pack" # untouched
# counted as already-present, so the one-time seed considers itself done
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
@@ -160,10 +161,10 @@ def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod
bogus.parent.mkdir(parents=True, exist_ok=True)
bogus.mkdir() # user (or junk) placed a directory where the pack goes
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert bogus.is_dir() # untouched
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
@@ -172,15 +173,15 @@ def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatc
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setattr(
server_mod,
"_BUILTIN_STARTER_SOURCES",
builtin_content,
"BUILTIN_STARTER_SOURCES",
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
)
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
assert not (dlc / builtin_content.BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
assert not (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).exists()
def test_every_starter_source_file_is_present(server_mod):
@@ -190,7 +191,7 @@ def test_every_starter_source_file_is_present(server_mod):
the checkout is clean, so "on disk" == committed."""
root = server_mod._feedBack_server_root()
missing = [
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
rel for _, rel in builtin_content.BUILTIN_STARTER_SOURCES
if not (root / rel).is_file()
]
assert not missing, f"listed starter sources missing on disk: {missing}"
@@ -199,18 +200,18 @@ def test_every_starter_source_file_is_present(server_mod):
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
"""A real seed run copies every listed pack into starter/ and marks done."""
root = server_mod._feedBack_server_root()
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
for _, rel in builtin_content.BUILTIN_STARTER_SOURCES:
if not (root / rel).is_file():
pytest.skip(f"starter source not present in checkout: {rel}")
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
builtin_content.seed_builtin_starter_content(server_mod._feedBack_server_root(), dlc)
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
for dest_name, _ in builtin_content.BUILTIN_STARTER_SOURCES:
dest = dlc / builtin_content.BUILTIN_STARTER_SUBDIR / dest_name
assert dest.is_file(), f"pack not seeded: {dest_name}"
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
assert (server_mod.CONFIG_DIR / builtin_content.STARTER_SEED_MARKER).is_file()
def test_no_unlisted_starter_pack_on_disk(server_mod):
@@ -220,7 +221,7 @@ def test_no_unlisted_starter_pack_on_disk(server_mod):
main before being wired up. In CI the checkout is clean, so this flags any
stray/committed pack that isn't listed."""
root = server_mod._feedBack_server_root()
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
listed = {rel for _, rel in builtin_content.BUILTIN_STARTER_SOURCES}
if not listed:
pytest.skip("no starter sources declared")
content_dir = (root / next(iter(listed))).parent # all sources share this dir
+205
View File
@@ -0,0 +1,205 @@
"""The plugin context is a THIRD-PARTY CONTRACT. Pin it.
`context` is handed to every plugin's `setup()`. Plugins — including ones we don't ship
and can't grep — read keys out of it and hold the callables as live references. Issue #48
flagged this while planning the server.py split and asked for exactly this assertion:
"Plugin context[...] are passed as live references into already-loaded plugins.
Refactoring must preserve the exact callables moving them to a new module is
fine, but renaming or wrapping them breaks third-party plugins. We'd want a
'plugin context unchanged' assertion in CI."
It doesn't exist yet, and server.py is about to be carved apart around the code that
builds it. This is the guard that makes the carve safe: a key silently dropped or
renamed by a move is invisible to every other test in the suite (nothing in-tree reads
most of these) and would break plugins at runtime, in the field.
Same lesson the frontend carve learned the hard way: a contract that only external code
reads cannot be found by a call-graph scan, so it has to be pinned by name.
WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from the source would
assert the code equals itself. The whole point is that a human has to look at a diff and
consciously agree to change the contract.
"""
import ast
from pathlib import Path
import pytest
SERVER_PY = Path(__file__).resolve().parents[1] / "server.py"
PLUGINS_PY = Path(__file__).resolve().parents[1] / "plugins" / "__init__.py"
# The keys server.py puts in the shared context handed to register_plugin_api().
BASE_CONTEXT_KEYS = {
"config_dir",
"get_dlc_dir",
"extract_meta",
"meta_db",
"get_scan_status",
"get_art_cache_dir",
"library_providers",
"register_library_provider",
"unregister_library_provider",
"register_tuning_provider",
"unregister_tuning_provider",
"get_sloppak_cache_dir",
"register_demo_janitor_hook",
"award_xp",
"get_xp_progress",
"seed_xp",
"reset_xp",
"record_progression_event",
}
# Added PER PLUGIN by plugins/__init__.py on top of the base — so the surface a plugin
# actually sees is the union. Real shipped plugins read `log` and `load_sibling`, and
# neither is in server.py's dict; a test that pinned only the base would miss them.
PER_PLUGIN_KEYS = {"load_sibling", "log"}
FULL_CONTEXT = BASE_CONTEXT_KEYS | PER_PLUGIN_KEYS
def _plugin_context_keys() -> set:
"""The literal keys of server.py's `plugin_context = {...}`, read from the AST.
AST, not a regex: the dict spans ~40 lines and is dense with comments, lambdas and
nested calls, and the values contain braces of their own.
"""
tree = ast.parse(SERVER_PY.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if (
isinstance(node, ast.Assign)
and node.targets
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == "plugin_context"
and isinstance(node.value, ast.Dict)
):
keys = set()
for k in node.value.keys:
assert isinstance(k, ast.Constant), (
"plugin_context must be built from literal string keys — a computed "
"key makes this contract un-reviewable"
)
keys.add(k.value)
return keys
pytest.fail(
"server.py no longer builds a literal `plugin_context = {...}` dict. If it moved "
"to another module, point this test at that module — do NOT delete it."
)
def test_plugin_context_keys_are_exactly_the_pinned_contract():
actual = _plugin_context_keys()
missing = BASE_CONTEXT_KEYS - actual
added = actual - BASE_CONTEXT_KEYS
assert not missing, (
f"plugin_context lost {sorted(missing)}. Every one of these is read by plugins we "
"do not control and cannot grep. Dropping one breaks them at runtime, in the "
"field, with nothing else in this suite failing."
)
assert not added, (
f"plugin_context gained {sorted(added)}. That's fine — but it is a PUBLIC API "
"addition, so add the key to BASE_CONTEXT_KEYS here deliberately, and document it "
"in docs/. This test exists to make that a conscious act rather than a side effect."
)
def test_per_plugin_keys_are_still_layered_on_top():
"""`log` and `load_sibling` are added per-plugin in plugins/__init__.py, not by
server.py so they're invisible to the check above. Real plugins read both."""
src = PLUGINS_PY.read_text(encoding="utf-8")
for key in sorted(PER_PLUGIN_KEYS):
assert f'plugin_context["{key}"]' in src, (
f"plugins/__init__.py no longer sets plugin_context[{key!r}] — shipped plugins "
"read it"
)
def test_context_values_reach_a_REAL_plugin_by_identity(tmp_path, reset_plugin_state):
"""The contract is CALLABLE IDENTITY, not just key names.
A carve that moves these into a module and re-exports them through a wrapper (a
property, a functools.partial, a lazily-bound getter) keeps every key name intact and
STILL breaks plugins that stored the reference at setup() time.
Codex [P2] on the first cut of this test, and it was right: I originally built a dict
locally and called setup() on it, which asserts `dict(x)['k'] is x['k']` trivially
true, and blind to everything plugins/__init__.py does. It has to go through the REAL
loader, because the real loader is exactly what copies and re-binds the context.
(That is not hypothetical: `register_library_provider` IS deliberately wrapped by the
loader, per-plugin, to force owner attribution. Pinned below so the one intentional
exception can't quietly become two.)
"""
from fastapi import FastAPI
# reset_plugin_state (tests/conftest.py) is the ONLY safe way to drive the real
# load_plugins(): it also mutates sys.path, sys.modules and PENDING_PLUGINS, and a
# hand-rolled partial restore makes the suite order- and environment-dependent.
# Codex [P2] on the first cut of this, and it was right.
plugins_mod = reset_plugin_state
plugin_dir = tmp_path / "ctxprobe"
plugin_dir.mkdir()
(plugin_dir / "plugin.json").write_text(
'{"id": "ctxprobe", "name": "ctx probe", "routes": "routes.py"}'
)
# A backend plugin's entry point is routes.py's `setup(app, ctx)` — the same shape
# tests/test_plugins.py::_make_plugin uses. The probe hands the context BACK through a
# sink in the context itself: importing the probe module by name does not work (the
# loader namespaces plugin modules), and a file/JSON channel would lose the object
# IDENTITY that is the entire point of this test.
(plugin_dir / "routes.py").write_text(
"def setup(app, ctx):\n"
" ctx['_probe_sink'].append(ctx)\n"
)
sentinel_db = object()
def sentinel_extract(_p):
return {}
def sentinel_register_library_provider(provider, *a, **kw):
return None
sink = []
context = {
"_probe_sink": sink,
"meta_db": sentinel_db,
"extract_meta": sentinel_extract,
"config_dir": tmp_path,
"register_library_provider": sentinel_register_library_provider,
}
app = FastAPI()
saved_dir = plugins_mod.PLUGINS_DIR
plugins_mod.PLUGINS_DIR = tmp_path
try:
plugins_mod.load_plugins(app, context)
finally:
plugins_mod.PLUGINS_DIR = saved_dir
assert sink, "the probe plugin's setup() never ran — the harness is not exercising the loader"
seen = sink[0]
assert seen["meta_db"] is sentinel_db, "meta_db must reach a real plugin BY IDENTITY"
assert seen["extract_meta"] is sentinel_extract, (
"extract_meta must reach a real plugin BY IDENTITY — wrapping it (partial, "
"property, re-binding getter) breaks plugins that stored the reference at setup()"
)
assert seen["config_dir"] is context["config_dir"]
# The loader adds these per-plugin; shipped plugins read both.
assert callable(seen["load_sibling"])
assert seen["log"].name == "feedBack.plugin.ctxprobe"
# THE ONE DELIBERATE WRAPPER. register_library_provider is scoped per-plugin so a
# plugin cannot forge owner attribution and impersonate another. Pinned so that the
# single intentional exception to identity cannot quietly become two.
assert seen["register_library_provider"] is not sentinel_register_library_provider, (
"register_library_provider is supposed to be wrapped per-plugin for owner "
"attribution — if that wrapper is gone, a plugin can impersonate another"
)
+8 -2
View File
@@ -186,10 +186,16 @@ def test_app_event_bus_dispatches_locally_and_preserves_juce_stop_state():
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
assert "this.dispatchEvent(new CustomEvent(event, { detail }))" in source
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying" in source
assert "sm.emit('song:resume', payload)" in source
# `isPlaying` moved onto the shared player-state container (static/js/player-state.js)
# so a carved module can WRITE it — an imported binding is read-only.
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying" in source
assert "window.feedBack.emit('song:resume', payload)" in source
# The JUCE audio-element shim — which re-emits song:resume through the session
# manager when JUCE owns the transport — was carved out into its own module (R3a).
juce = (ROOT / "static" / "js" / "juce-audio.js").read_text(encoding="utf-8")
assert "sm.emit('song:resume', payload)" in juce
def test_nam_and_stems_use_owner_claim_dispatch_semantics():
nam_source = _sibling_text("feedBack-plugin-nam-tone", "screen.js", "NAM_STEM_CLAIM_ID = 'nam.amp-active'")
+116
View File
@@ -138,3 +138,119 @@ def test_unready_plugin_src_is_404(client):
c, _ = client
plugins.LOADED_PLUGINS[0]["status"] = "installing"
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
# ── #879: the /g/<token>/ generation prefix ────────────────────────────────────
#
# A plugin ROLLBACK must actually re-evaluate a module plugin. ES modules are
# evaluated once per URL per document, so re-inserting a <script type="module">
# whose src the module map has already seen fires `load` without re-running the
# body. Busting the ENTRY url alone does not help — screen.js is a one-line
# `import './src/main.js'`, and a relative specifier resolves against the base URL
# with the QUERY DROPPED, so a ?v= token never reaches the graph.
#
# Hence a token in the PATH: every relative import inherits it, at every depth,
# with no import-specifier rewriting. These routes must serve the SAME bytes and
# keep the SAME containment.
def test_generation_prefix_serves_identical_screen_js(client):
c, _ = client
plain = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/screen.js")
assert gen.status_code == 200
assert gen.content == plain.content
assert "import './src/main.js'" in gen.text
def test_generation_prefix_serves_the_whole_module_graph(client):
"""The point of the path token: a relative import from a /g/7/ entry resolves
to a /g/7/ URL, so the graph is fetched fresh not just the entry."""
c, _ = client
main = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/src/main.js")
assert main.status_code == 200
assert main.text == c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").text
# and one level deeper, which is where a query-string token would already have
# been lost twice over
nested = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/src/util/x.js")
assert nested.status_code == 200
assert "export const x = 42" in nested.text
def test_generation_token_is_opaque(client):
"""Any token serves the same bytes — it exists only to vary the URL."""
c, _ = client
a = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/main.js")
b = c.get(f"/api/plugins/{PLUGIN_ID}/g/999999/src/main.js")
assert a.status_code == b.status_code == 200
assert a.text == b.text
def test_generation_prefix_does_not_widen_containment(client):
"""The token is never joined into a path, so containment must be EXACTLY what the
un-prefixed route already gives. Asserted as parity rather than as a flat 404:
`../screen.js` legitimately 200s on BOTH, because the URL normalises to
/api/plugins/<id>/screen.js before routing ever happens it never leaves the
plugin dir. Pinning an absolute expectation here would have encoded my guess
about the existing route instead of testing the thing that matters, which is
that /g/ changes nothing."""
c, _ = client
for bad in ("../screen.js", "../../etc/passwd", "..%2f..%2fetc%2fpasswd",
"..%5c..%5cwindows%5cwin.ini", "/etc/passwd"):
plain = c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}")
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/{bad}")
assert gen.status_code == plain.status_code, f"/g/ diverged on {bad!r}"
assert gen.content == plain.content, f"/g/ served different bytes for {bad!r}"
assert "root:" not in gen.text and "[extensions]" not in gen.text
# and the real traversals are genuinely rejected, on both
for bad in ("../../etc/passwd", "..%2f..%2fetc%2fpasswd"):
assert c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/{bad}").status_code == 404
def test_generation_prefix_404s_for_unknown_plugin(client):
c, _ = client
assert c.get("/api/plugins/nope/g/1/screen.js").status_code == 404
assert c.get("/api/plugins/nope/g/1/src/main.js").status_code == 404
def test_generation_prefix_serves_ASSETS_too(client):
"""Codex [P2] on the first cut of this fix, and it was right.
The path token shifts the BASE URL, so everything a module resolves relatively moves
with it not just imports. `new URL('../assets/worklet.js', import.meta.url)` from
/api/plugins/<id>/g/1/src/main.js resolves to /api/plugins/<id>/g/1/assets/worklet.js.
Mirroring only screen.js and src/ would have fixed imports and 404'd every asset,
worklet and wasm file the graph reaches. Hence a path REWRITE, so every plugin route
present and future works under the prefix."""
c, _ = client
plain = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/assets/worklet.js")
assert plain.status_code == 200
assert gen.status_code == 200, "an asset reached relatively from a reloaded module graph 404'd"
assert gen.content == plain.content
def test_generation_prefix_covers_every_plugin_route(client):
"""The rewrite is generic, so this holds for routes nobody thought about — which is
the point. Any plugin route added later works under /g/ with no extra wiring."""
c, _ = client
for route in ("screen.js", "src/main.js", "src/util/x.js", "src/theme.css",
"assets/worklet.js", "settings.html"):
plain = c.get(f"/api/plugins/{PLUGIN_ID}/{route}")
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/42/{route}")
assert gen.status_code == plain.status_code, f"/g/ diverged on {route}"
assert gen.content == plain.content, f"/g/ served different bytes for {route}"
def test_generation_prefix_handles_non_ascii_filenames(client):
"""Codex [P3] on the second cut. A plugin file named e.g. src/工具.js is perfectly
valid, and the middleware must not 500 on it which an eager
raw_path.encode("latin-1") did, making the prefixed route LESS capable than the
plain one. raw_path is informational; Starlette routes on scope["path"]."""
c, tmp = client
(tmp / "src" / "工具.js").write_text("export const t = 1;\n")
plain = c.get(f"/api/plugins/{PLUGIN_ID}/src/工具.js")
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/3/src/工具.js")
assert plain.status_code == 200
assert gen.status_code == 200, "non-ASCII module path 500'd or 404'd under /g/"
assert gen.content == plain.content
-50
View File
@@ -46,56 +46,6 @@ def capture_logger(caplog, logger_name, level=logging.WARNING):
logger.propagate = orig_propagate
# Bare module names that this test module pre-populates into
# sys.modules to simulate the bare-import path. Saved/restored by
# the reset_plugin_state fixture so they don't leak to other test
# files. Codex / Copilot review on PR for feedBack#33.
_BARE_NAMES_USED = ("util", "extractor")
@pytest.fixture()
def reset_plugin_state(monkeypatch):
"""Clear loader module-level state and restore on teardown.
Saves and restores:
* `plugins.LOADED_PLUGINS`
* any `plugin_*` keys we add to `sys.modules`
* the bare names this module simulates (`util`, `extractor`)
* `sys.path` `plugins.load_plugins()` mutates it
Also unsets `FEEDBACK_PLUGINS_DIR` for the test's duration
(via monkeypatch) so a CI env that pre-sets it can't leak
real user plugins into a tmp_path-driven test. Per-module
locks are owned by the standard import system
(`importlib._bootstrap._module_locks`) and are not our
responsibility to reset.
"""
monkeypatch.delenv("FEEDBACK_PLUGINS_DIR", raising=False)
plugins = importlib.import_module("plugins")
saved_loaded = list(plugins.LOADED_PLUGINS)
saved_pending = dict(plugins.PENDING_PLUGINS)
saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("plugin_")}
saved_bare = {k: sys.modules[k] for k in _BARE_NAMES_USED if k in sys.modules}
saved_path = list(sys.path)
plugins.LOADED_PLUGINS.clear()
plugins.PENDING_PLUGINS.clear()
for k in list(sys.modules):
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
del sys.modules[k]
try:
yield plugins
finally:
plugins.LOADED_PLUGINS.clear()
plugins.LOADED_PLUGINS.extend(saved_loaded)
plugins.PENDING_PLUGINS.clear()
plugins.PENDING_PLUGINS.update(saved_pending)
for k in list(sys.modules):
if k.startswith("plugin_") or k in _BARE_NAMES_USED:
del sys.modules[k]
sys.modules.update(saved_modules)
sys.modules.update(saved_bare)
sys.path[:] = saved_path
def _make_plugin(plugin_root, plugin_id, *, sibling_files=None, routes_body=None):
"""Create a minimal plugin directory under `plugin_root`.
+5 -4
View File
@@ -5,6 +5,7 @@ import importlib
import json
import sys
import builtin_content
import pytest
from fastapi.testclient import TestClient
@@ -192,7 +193,7 @@ def test_low_accuracy_play_does_not_complete_gated_challenge(client):
def test_diagnostic_at_100_completes_calibration(client, server):
diag = server._builtin_diagnostic_filename()
diag = builtin_content.builtin_diagnostic_filename()
# A near-miss leaves calibration pending.
_scored_play(client, filename=diag, accuracy=0.97, score=500)
assert client.get("/api/progression").json()["onboarding"]["calibration_status"] == "pending"
@@ -207,7 +208,7 @@ def test_diagnostic_play_does_not_feed_challenges_or_quests(client, server):
# The calibration run is a perfect guitar play — it must yield rank 1
# EXACTLY, advancing neither the guitar path nor the daily song quest.
client.post("/api/progression/paths", json={"add": ["guitar"]})
r = _scored_play(client, filename=server._builtin_diagnostic_filename(),
r = _scored_play(client, filename=builtin_content.builtin_diagnostic_filename(),
accuracy=1.0, score=500)
summary = r.json()["progression"]
assert summary["calibration_completed"] is True
@@ -228,7 +229,7 @@ def test_pathless_diagnostic_run_still_completes_calibration(client, server):
run is an earned achievement and must count even before any path is
selected (e.g. a pre-progression profile playing the diagnostic as a
hardware test) yielding a valid pathless rank-1 state."""
_scored_play(client, filename=server._builtin_diagnostic_filename(),
_scored_play(client, filename=builtin_content.builtin_diagnostic_filename(),
accuracy=1.0, score=500)
data = client.get("/api/progression").json()
assert data["onboarding"]["calibration_status"] == "completed"
@@ -240,7 +241,7 @@ def test_diagnostic_upgrades_skipped_without_rank_change(client, server):
client.post("/api/progression/paths", json={"add": ["guitar"]})
r = client.post("/api/progression/onboarding", json={"action": "skip"})
assert r.json()["onboarding"]["calibration_status"] == "skipped"
_scored_play(client, filename=server._builtin_diagnostic_filename(), accuracy=1.0, score=500)
_scored_play(client, filename=builtin_content.builtin_diagnostic_filename(), accuracy=1.0, score=500)
data = client.get("/api/progression").json()
assert data["onboarding"]["calibration_status"] == "completed"
assert data["mastery_rank"] == 1