Starting a gig dropped the player onto the fallback 2D highway with no venue.
startGig() calls setViz('venue'), which installs the 3D renderer — whose init is
async — and then immediately starts its play queue. playSong() re-initialises
that same renderer a tick later. A renderer mints a fresh readyPromise per
init() and rejects the previous one with "superseded"; highway.js only checked
that the RENDERER OBJECT was unchanged, which it is. So it treated a healthy,
re-initialising renderer as a failed one, tore it down, and reverted to 2D:
renderer async init failure: Error: superseded
viz picker: reverted to default renderer (async-init-failure)
The guard now also checks the PROMISE identity: a rejection from an init cycle
the renderer has already moved on from is ignored. The renderer-identity guard
stays (a rejection for a renderer since REPLACED is also not ours), and a
genuine failure of the CURRENT cycle still reverts — both init() call sites go
through _setRenderer, which re-wires the handler every time, so the new cycle is
always watched.
Reproduced and fixed against the real build:
before: vizSelection=default viz-picker=default venue=inactive viz:reverted
after: vizSelection=venue viz-picker=venue venue=ACTIVE (no revert)
Also widens the paused-frame throttle's opt-out. The throttle fires whenever the
CHART CLOCK is stalled — not only on a pause, but through a count-in and the
credits/author overlay too. Its opt-out only asked "is a crowd video rolling",
but the venue scene animates on a clock of its own with no pack at all (backdrop
breathe, parallax, haze drift, warmth pulse — Math.sin(t) in the draw loop), so
that motion was still being throttled. It now claims frames for both sources; a
plain 3D highway with no venue reads motion mode 'off' and keeps the #654 GPU
saving.
HONEST CAVEAT on that second part: I could not get the throttle to fire in a
reproduction. A control run on the shipped code showed 100 draws/sec while
paused, not the ~10/sec a firing throttle would give — so the change is
defensible on its own terms (a stalled clock is genuinely not a static picture)
but it does NOT have a demonstrated symptom behind it. The viz fix above does.
Tests: the superseded guard, and that the throttle opt-out covers both motion
sources. All fail against the pre-fix source. eslint 0 errors; JS 1207/1207.
* fix(venue): don't replay the flyover on an arrangement switch; keep the venue off other screens
Two bugs from a live career session.
1. CHANGING ARRANGEMENT REPLAYED THE ARRIVAL FLYOVER.
changeArrangement() reloads the song through the normal load path, so
highway.js re-emits `song:loaded` — same filename, new arrangement. The venue
could not tell that from a fresh arrival, so it reset the machine and flew the
camera in from the back of the room again, mid-set, every time the player
switched lead -> rhythm. The player is already on stage.
onSongLoaded now compares the filename. A repeat of the song already on stage
keeps the video pipeline running and only re-syncs the mood: the performance
restarts, so the loop follows the reset machine with a quiet crossfade, never
the intro. A genuinely different song still gets the full teardown + flyover.
2. THE VENUE SHOWED UP ON THE VIRTUOSO HIGHWAY.
The venue was gated purely on `isVenueViz()` — the selected visualization,
which is a GLOBAL preference and says nothing about what is on screen.
Virtuoso borrows the same highway_3d renderer for its practice charts, so with
Venue selected it inherited the backdrop: the crowd and the stage behind a
chromatic exercise.
Selecting Venue is a preference for the PLAYER; it is not a licence to paint
the venue over whatever else happens to be using the renderer. The venue is now
gated on viz AND screen (`shouldBeActive`), and follows `screen:changed` — it
tears down on leaving the player and rebuilds on return. Nothing else changes:
stop() already unbinds the videos from the renderer, so deactivating is enough
to clear the backdrop.
Tests: both decisions exposed as pure predicates and pinned — arrangement switch
vs new song (including the first load, and a malformed payload that must not
suppress the flyover forever), and the venue's screen scope. The existing syncViz
test encoded the OLD contract (activate regardless of screen), so it now states
the new one and additionally asserts the venue does NOT activate on virtuoso.
Includes a guard test: with Venue selected AND on the player, the venue IS
active — without it, every "not active" assertion could pass vacuously.
All 8 new/updated assertions fail against the pre-fix source. eslint clean;
JS 1199/1199; pytest 2597 passed.
* fix(highway): the paused-frame throttle was throttling the whole venue
Pausing the song dropped the venue, the crowd and the stage to ~10 fps —
"everything around the highway drops fps by a lot".
draw() caps paused frames to one per _PAUSED_FRAME_INTERVAL_MS (100ms), on an
assumption stated plainly in highway-constants.js: a heavy WebGL renderer "does
a full render every frame even while paused. That is pure waste." That was true
when a paused chart was a still picture.
The venue broke the assumption. Its video backdrop keeps playing and its crowd
reacts on a clock of their own, and BOTH are drawn into the same canvas as the
notes — so a throttle aimed at static notes throttled the entire room. The
scene only got a texture upload 10 times a second while the transport sat
paused.
Renderers can now declare that their picture is not static while the chart
clock is stopped: an optional needsContinuousFrames(). The throttle is skipped
only when it returns exactly true, and the probe fails closed — a renderer that
doesn't implement it, or one that throws, keeps the throttle unchanged. So the
GPU saving that motivated #654 survives everywhere it was actually valid.
highway_3d implements it and claims continuous frames ONLY while a crowd video
is genuinely rolling (bound, unpaused, not ended, readyState >= 2). With no
venue pack — the common case — the paused scene really is static, so it keeps
the throttle and the GPU still idles.
Tests extend tests/js/highway_pause_throttle.test.js, which guards this code
path source-level (the draw loop owns the rAF + WebGL lifecycle and is
deliberately not reproduced in a vm — see the file header). The new guards pin
that the capability GATES the early return rather than merely being called near
it, that the probe fails closed on absent/non-function/throwing/truthy-but-not-
true, and that the 3D renderer keys off the real video elements and can still
return false. All 3 fail against the pre-fix source.
eslint 0 errors; JS 1202/1202; pytest 2597 passed.
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(highway_3d): size Butterchurn output canvas buffer to fill the highway
The 3D-highway Butterchurn background set only the output canvas CSS size
and called setRendererSize(), but never sized the canvas DRAWING BUFFER
(canvas.width/height). Butterchurn does not size the output canvas itself
(renderToScreen viewports to the reported size into the default
framebuffer), so the buffer stayed at the browser default 300x150 while the
viewport was the full highway. Only the bottom-left ~300x150 of the pattern
was drawn, then CSS-stretched across the whole highway -- zoomed, soft, and
aspect-wrong, worse the larger the panel.
Add _bcApplySize(cssW, cssH): set the drawing buffer to the device-pixel
render size (round(css * min(DPR, 1.5))), confine every layer (canvas,
backdrop, scrim, tint) to the highway rect, and report the same device px
to setRendererSize so buffer == on-screen viewport. Seed the buffer at
create and switch createVisualizer to pixelRatio:1, textureRatio:1 (DPR is
now folded into the reported size, so buffer == viewport == internal
texsize, no double-counting). render() and resize() both route through it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* chore(highway_3d): bump to 3.31.5 (3.31.4 taken by #823 on main)
Rebased onto main; #823 already shipped 3.31.4 (per-panel camera), so this
Butterchurn buffer-sizing fix advances to 3.31.5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
_resolveFreeCam() (keys/drum) and _freeCamFor() (highway_3d) guarded the panel
map lookup with only `i != null`, so a non-integer / negative / string index
from panelIndexFor() could resolve an unintended or inherited property (e.g.
map['toString']) instead of cleanly falling back to the global camera. Gate the
index on `Number.isInteger(i) && i >= 0` before `map[i]`, matching the hardening
already applied in _bgPanelKey(). Extend the resolver tests with float/string
(prototype-key) cases. Behavior change only for malformed indices.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
- _bgPanelKey() treated any non-null panelIndexFor() return as a valid panel id,
so a NaN/non-finite index minted a bogus "panelNaN" localStorage key instead
of falling back to "main". Gate on Number.isInteger(idx) && idx >= 0. (The
camera path is already NaN-safe — panelsMap[NaN] misses and falls through.)
- Add a NaN/negative-index case to the resolver tests (drum 22, keys 57, pass).
- Convert the camera-bridge helpers' comments to JSDoc (_bgPanelKey, _freeCamFor,
_resolveFreeCam, _ssApi across the three plugins) to lift docstring coverage on
the changed surface. Comment/robustness only; no behavior change beyond the
NaN guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Follow-up to the _bgPanelKey alias fix: _freeCamFor already treats
panelIndexFor as potentially throwy and catches to keep framing stable, but
_bgPanelKey called it bare. A throwing splitscreen build would take down
background-settings resolution (and the render path) even though the camera
path falls back safely. Wrap the call in try/catch, falling back to 'main'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Three review findings on the per-panel camera work:
- highway_3d: _bgPanelKey() resolved splitscreen via window.feedBackSplitscreen
only, while _freeCamFor() uses the feedBackSplitscreen||slopsmithSplitscreen
alias it claims to "mirror". If the rename lands, per-panel background settings
would silently stop being per-panel while the camera stayed per-panel. Resolve
the alias the same way in _bgPanelKey.
- drum_highway_3d: applyCamera()'s "before first positionCamera()" guard tested
`_camBaseH == null`, but _camBaseH/_camBaseD were initialized to 0, so the guard
never fired (and could apply a base-0 pose for a frame). Initialize to null.
- keys + drum: the PR claimed the Camera Director resolver was unit-checked, but
nothing exercised it. Extract the resolver into pure, exported helpers
(_resolveFreeCam + _ssApi), delegate the per-instance _freeCamFor to them, and
add tests/camera_bridge.test.js covering per-panel select, global fallback,
null-when-absent, throw-safety, and the slopsmith-alias resolution. Drum 15→21,
keys 50→56, all pass; behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Address a review note on the free-camera block: the comments described the
bridge as "per-panel-aware" without naming the actual globals. Spell out that
_freeCam comes from _freeCamFor(highwayCanvas) — window.__h3dCamCtlPanels[
panelIndexFor(canvas)] when split, else the global window.__h3dCamCtl, else
null — and update the nearby comment that mentioned only __h3dCamCtl. Comment-
only; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Make the three 3D highways read the Camera Director bridge per panel so each
splitscreen panel renders its own camera (independent orbit/height/zoom/tilt/pan),
instead of all panels sharing the focused camera.
- Add a shared `_freeCamFor(canvas)` resolver to each highway: prefer this panel's
entry in `window.__h3dCamCtlPanels[panelIndexFor(canvas)]`, fall back to the
global `window.__h3dCamCtl`, else null (100% stock). Defensive on the splitscreen
global name (feedBackSplitscreen || slopsmithSplitscreen), NaN-safe, allocation-free.
- highway_3d (guitar): source `_freeCam` from the resolver (was global-only).
- keys_highway_3d: adopt the bridge for the first time — layer dolly/height/orbit +
pan/pitch offsets onto the pan/zoom follow rig at the camera write.
- drum_highway_3d: adopt the bridge — new per-frame `applyCamera()` folds the static
base pose + kick-pulse dip + free-cam offsets.
- In a follower (popped-out) window there is one panel, so the resolver yields
whatever camera the plugin set in that window; no highway change needed for pop-out.
Camera Director absent → resolver returns null → renderers behave exactly as before.
Bump each plugin patch version. Existing plugin tests pass (drum 15, keys 30); the
keys "default look unchanged" test confirms the stock path is byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
Switching the active window / alt-tabbing away (most often on Windows) can
trigger a GPU context reset. The 3D highway's WebGL renderer had no
webglcontextlost handler, so a lost context was left to escalate into a
render-process crash -- matching the intermittent "randomly crashes when I
change windows" desktop reports.
The renderer now binds webglcontextlost/webglcontextrestored on its own WebGL
canvas (ren.domElement): the loss is preventDefault()'d so the browser keeps the
context restorable, draw() bails while the context is down so no GL work runs on
a dead context, and on restore the viewport is re-applied and rendering resumes
(Three re-uploads scene resources on the next frame). Listeners are removed in
teardown.
Root cause is a strong hypothesis -- the crash is intermittent and
unreproducible -- but the fix is low-risk and additive and closes a real gap:
there was no context-loss handling anywhere in the renderer.
plugins/highway_3d 3.31.2 -> 3.31.3. Tests:
tests/js/highway_3d_context_loss.test.js (source-contract, like the other
highway_* tests). The sibling keys_highway_3d / drum_highway_3d renderers share
the same gap -- follow-up in their repos.
Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- BG_STYLES port (off/particles/lights/geometric; lights use the
pitch-class palette) mounted behind the scene; _bgGetAnalyser/
_bgReadBands (stems-first, one-shot #audio fallback, permanent-failure
latch); Ambience intensity + Audio-reactive settings; remounts on
style/intensity change
- Score-FX overlay canvas (drum_highway_3d pattern): +1 pops at the
scored key, ring pulse every 10-combo, milestone bursts at 25/50/100,
red wash on 3+ streak break (wrong notes AND swept misses); cleared
when idle, removed in teardown
- Tests: style id validation + FX defaults (30 total)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The retrace after the label-swap fix showed getParameters unchanged
(~2.5s / ~4% throttled main thread) — the real driver is Three r158+'s
transparent-DoubleSide two-pass path: renderBufferDirect renders such
objects back side then front side, setting material.needsUpdate BOTH
times, i.e. a full getParameters/program-cache lookup twice per object
per frame, plus double draw calls. (Found by reading the two-pass
branch in the vendored three.module.min.js right next to the
getParameters call site.)
All 18 transparent DoubleSide materials in this renderer are flat
unlit quads — technique markers, sustain rails, chord frames, lane
planes, halo bars — where the two-pass self-occlusion ordering buys
nothing. Declare forceSinglePass: true on all of them.
Also corrects the _setLabelMap comment's churn attribution (that fix
removes the label-swap contribution; this one removes the dominant
source). Plugin 3.31.1 -> 3.31.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 4x-CPU-throttled retrace (the honest weak-hardware proxy) surfaced
three residual per-frame costs; stack attribution pinned each:
- getParameters/getProgramCacheKey (~4% of main thread): every pooled
label sprite map swap set material.needsUpdate, bumping
material.version and forcing full program re-resolution next render.
Swapping between two non-null cached textures never changes the
compiled program (USE_MAP define unchanged) — new _setLabelMap()
helper only flags needsUpdate on a null<->texture transition, used at
all 7 swap sites.
- getBoundingClientRect (~1.2%): the 3D highway's per-frame canvas-size
self-check forced a layout read every frame. The CSS-box drift read
now runs every 10th frame (or when the wrap isn't pinned); the
backing-store comparison stays per-frame with cheap property reads
and forces an immediate box read + applySize when it fires.
- set textContent: the core 60 Hz HUD clock rewrote hud-time (and
getElementById'd it) every tick for a display that changes 1/s — now
write-on-change with a cached element ref.
(The remaining textContent writer in the trace is notedetect's
badges.js — external repo, to be filed there.)
tests/js: resize-reframe shape test updated for the hoisted _bsChanged
gate, incl. an assertion that the throttle can never delay the
backing-store path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- isVisible() forces a fresh DOM sample (was serving the rAF loop's
throttled cache, contradicting its 'live DOM check' docstring).
- v3 chrome: reconcile the edge-driven overControls hover flag against
matches(':hover') on the throttled ~6 Hz tick — covers a missed
mouseleave (flag stuck true, transport never hides) and a re-created
#player-controls node with lost listeners.
- highway_3d pre-warm now also covers teachFg/teachSd label textures
and the technique sprite factories (mute X, hammer/pull triangles,
bend chevrons, slide arrows) per active-palette string colour, plus
a maintenance note tying new label styles to the warm list.
- Document that the visibility throttle's manual invalidations are
latency-only (periodic resample self-heals within ~10 frames), and
why highway_3d keeps its local lowerBoundT (downlevel hosts).
External-repo audit (finding 1): staffview, tabview, piano, drums,
keys_highway_3d, drum_highway_3d grepped — no cross-frame bundle
retention or bundle-identity checks found.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Static-analysis follow-ups to the trace-backed fixes; each is cheap
insurance on machines where the profiled headroom doesn't exist.
- highway.js: _makeBundle now mutates one persistent per-instance
object instead of allocating a fresh ~35-field bundle every rAF
frame (xN under splitscreen). Object identity is stable and
meaningless; array fields still swap reference on chart changes,
which field-identity caches rely on. Contract documented in both
CLAUDE.mds.
- highway.js: new bsearchTime (lower-bound on .time) windows the
default 2D renderer's beat-line scan (was O(all beats) per frame);
bundle.lowerBoundT / bundle.lowerBoundTime expose the searches to
custom viz so they stop reimplementing visible-window culling.
- highway_3d: localStorage 'h3d_full_sus' polled at ~1 Hz instead of
every frame (synchronous storage read on the hot path).
- highway_3d: drawLyrics caches the measureText row layout keyed on
(lyrics ref, line index, shown count, font size, width) — per-frame
work is now just drawing over cached widths.
- tests/js: bundle source-shape assertions widened to accept the
assignment form ([:=]) alongside the old object-literal form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trace showed frame spikes from Three.js first-use costs mid-song:
shader program compilation (getParameters/getProgramCacheKey) and lazy
texture uploads (texSubImage2D) whenever a chord name, section banner,
or fret label first appeared.
- ren.compile(scene, cam) after initScene (pools already warmed by
feedBack#226, board built, background mounted) so programs compile
during the load spinner.
- Pre-rasterise + GPU-upload (ren.initTexture) the deterministic txtMat
entries: fret numbers 0-24 in the noteFret/fretRow/ghostFret combos
the per-frame paths request.
- Chart-dependent labels (chord template names, section names) prewarm
once on the first draw() after each init, when bundle arrays are
guaranteed populated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Reset handler forced base.enabled = true after copying _ASPECT_DEFAULTS
(where enabled is false) — a leftover from when enabled controlled panel
visibility. Visibility is now independent (Shift+A / ×), so drop the override
and let Reset restore the defaults exactly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
camUpdate registered every pane each frame regardless of whether the tuner
had ever been opened, so window.__h3dAspectPanes could grow unbounded (prune
runs only while the panel is open) and it ran even for users who never opt
in. Gate _aspectRegisterPane behind __h3dAspectPanelOpen (same gate as the
readout). The pane key is still resolved every frame so saved overrides keep
applying; only the picker bookkeeping is deferred until the panel is open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
- Memoize _resolveTuneFor per pane, invalidated by a revision bumped on every
tune mutation (all writes funnel through _aspectPersist). Panes with an
override no longer rebuild the merged object every frame; panes without one
still return the base directly.
- _aspectNowMs falls back to Date.now() when the Performance API is absent, so
pane/readout pruning still works in older/borrowed contexts.
- _setAspectPanelVisible prunes stale panes before the first dropdown build, so
panes from a prior song/split don't flash until the first RAF tick.
- Rename _abShortcutRegistered/_registerAspectAbShortcut to
_tunerShortcutRegistered/_registerTunerShortcut — the shortcut opens/closes
the tuner now, it isn't an A/B toggle.
- Fix a stale 'pane1' example in a comment (keys are 'arr:<name>'/'pane:<uid>').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
When only one pane is live the Target row is hidden, but _aspectEditTarget
could remain a specific pane key — silently routing edits into a hidden
(and persistent arr:*) override in single-player. Reset the edit target to
"" in _aspectBuildTargets whenever the row is hidden (or the selected pane
is gone), so single-pane edits always go to the shared base.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
- Set type="button" on the × close control and the Reset/Copy buttons so
they can never act as submit if the panel is ever nested in a <form>.
- Add aria-label="Target pane" to the Target <select> so screen readers can
identify the control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
Three fixes from PR review of the per-pane tuner:
- Sync no longer writes back. _syncAspectPanel dispatches synthetic input
events to refresh slider labels; guard those with _aspectSyncing so the
slider handler skips the write. Previously opening/switching a target
populated a full override for every field (defeating sparse inherit) and
spammed localStorage.
- Unchecking "Override held hFOV" on a pane target now clears the override
key (via _aspectClearVal) so the pane re-inherits the base value, instead
of pinning hfovDeg:null in the override. On the base target it still sets
the explicit auto (null).
- _aspectPrunePanes now prunes the matching __h3dAspectReadout slot and drops
a dangling __last, so the readout cache can't grow unbounded as songs and
arrangements churn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
The Target picker disappeared in split because it keyed panes off the
external splitscreen panel index (panelIndexFor), which isn't always
available — both panes then collapsed to a single 'main' key and the
one-pane row-hide kicked in.
Key panes by arrangement name instead ('arr:Bass'): distinct between split
panes AND stable across songs, with no dependency on the split plugin. A
per-instance id ('pane:N') is the fallback when a pane has no arrangement.
Only arr:* overrides persist to localStorage (instance-id fallback keys are
session-only, so they can't leak a new key each reload). This also gives
nicer semantics — a pane's framing follows its arrangement into the next
song.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
Per-pane overrides were keyed by an ephemeral per-instance id, so leaving a
song and opening another rebuilt the renderer with a new id and the pane's
framing was lost.
Key overrides by the durable split slot again ('main' | 'panel<idx>', via
_bgPanelKey) so the same slot means the same pane across songs, and persist
__panels to localStorage. Keep the anti-flicker fixes that were the actual
cause of the earlier dropdown churn (prune stale panes, rebuild only on a
pane-set change, never rebuild while the select is focused). The slot key is
latched to the last real slot so a transient null from panelIndexFor during
a song/layout transition can't flip it to 'main' and drop the override for a
frame; it resets in destroy() for instance reuse in another slot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
The Target dropdown keyed panes off feedBackSplitscreen.panelIndexFor,
which can return the focused index for any canvas — so both split panes'
keys ping-ponged, rebuilding the <select> every frame (flicker) and
listing wrong/duplicate entries. The registry also never dropped panes
from a prior song or a closed split.
- Key each pane by a stable per-renderer-instance id (_paneUid, assigned
once in init) instead of the split panel index.
- Prune panes not reported within ~1.5s (song change / split teardown).
- Mark the dropdown dirty only when the pane SET changes, not on every
per-frame re-report, and skip rebuilding while the <select> is focused.
- Hide the Target row entirely when there's a single pane.
- Label panes by arrangement name, falling back to "Pane N".
Per-pane overrides are now session-only (keyed by ephemeral instance ids),
so they're no longer persisted to localStorage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
Two usability gaps in the wide-pane framing tuner:
- No way to dismiss the panel. Add a × close button to the header and make
the Shift+A shortcut open/close the panel (reveal/dismiss). The A/B
enabled toggle now lives as a checkbox in the panel, so closing the panel
no longer changes the framing state.
- Edits hit every split pane at once. Add a Target selector (All panes, or a
specific pane labelled by its arrangement, e.g. "Panel 1 — Rhythm"). Per-
pane edits write a sparse override map (__panels[key]); each renderer
resolves the shared base with its own pane's overrides laid on top via
_resolveTuneFor(paneKey), so one pane can be framed independently. Reset on
a pane clears its override (re-inherits the base); Copy exports the resolved
values for the selected target. The live readout is keyed per pane.
Panes are discovered from the existing per-panel key (_bgPanelKey /
feedBackSplitscreen.panelIndexFor) and self-register each frame for the
picker. Overrides persist to localStorage alongside the base.
Tests extended in tests/js/highway_3d_wide_fov.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* Add aspect-aware framing for ultra-wide highway panes
On a top/bottom 2-player split each 3D highway pane is full-width /
half-height (~32:9). The camera's vertical FOV was locked at a single
value, so at that aspect the horizontal cone ballooned past 130deg and
squeezed the fixed-width neck into a thin central sliver with large dead
margins on either side.
Add a "horizontal-FOV-hold" path: past a configurable start aspect the
effective vertical FOV is lowered so the horizontal cone stays roughly
constant, letting the neck fill a wide pane. At/under the start aspect it
is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are
unchanged. Optional pose nudges (height / dolly / pitch / look-depth)
further flatten the view toward a low, immersive angle.
Everything is driven by a runtime bridge (window.__h3dAspectTune) with a
live tuner panel (Shift+A in the player) exposing every knob plus a live
aspect/FOV readout, localStorage persistence, and a Copy button. Toggling
the feature off restores the exact prior framing, so it doubles as an A/B
control. Shipped on by default for wide panes for testing feedback.
Source-pinned by tests/js/highway_3d_wide_fov.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
* fix(highway_3d): ship wide-pane framing default-OFF with a coherent config
Review fixes for the aspect-aware framing. The first cut shipped
_ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 },
which contradicted the PR's own "default off → byte-for-byte prior behaviour"
claim:
- enabled:true made the tune active for everyone, and baseVfov:30 forced every
pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included —
a drastic global zoom, not the advertised no-op).
- blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV-
hold did nothing even on wide panes — the only net effect was the zoom.
- minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to
36° rather than flooring a real reduction).
New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1,
minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now:
- OFF by default → camUpdate passes a null tune → effectiveVfov returns
BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9).
- When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start
panes at 70° (still a no-op there) and blend:1 makes the hold actually engage
on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows).
- minVfovDeg < baseVfov is a real floor.
Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a
machine that persisted the old broken default gets the corrected one, and adds
source-pin tests guarding default-off + the coherent base/blend/floor so this
can't silently regress to default-on again. The pose-nudge values are left as
the author's in-progress wide-pane look (dormant until enabled). 110/110 tests
pass; node --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
The heat-coloured fret-number row is drawn as a band BELOW the board
(sY(lowest) - S_GAP*1.4), but camUpdate's self-correcting framing only
anchors the board CENTRE to the lower third of the screen and reserves no
headroom for that row. So a tight zoom on a centred active span (worst
mid-neck; fine at either end of the neck) pushes the numbers past the
bottom edge -- which is why testers saw it "only when centered" and "not
every song." Tilt can't fix it (it would only trade a bottom clip for a
top clip); the vertical-extent problem at tight zoom needs camera distance.
Add a fret-row fit guard: project the row band with the final camera and,
when it falls below FRET_ROW_FIT_NDC_MIN, raise a capped, hysteretic
_fretRowFitBoost applied to the curDist lerp target (the span-driven
tgtDist still owns zooming IN). The boost rises promptly (proportional to
the deficit), relaxes lazily past a deadband, and is capped at
FRET_ROW_FIT_BOOST_MAX (+60%) so the zoom can't pop or hunt. It cooperates
with the tilt loop (pull-back shrinks the scene, tilt keeps the centre
anchored) and yields entirely to the Camera Director free-cam. Surgical:
passages where the row is already visible never trigger it, so framing is
unchanged everywhere it already worked.
plugin.json 3.30.0 -> 3.30.2 (screen.js cache-buster; 3.30.1 is taken by the
FPS-counter PR). Tests: tests/js/highway_3d_camera_framing.test.js
(guard constants, the boosted curDist lerp, the projected-row hysteresis,
free-cam yield).
Fixes#632
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The on-highway FPS readout (Settings -> Graphics -> 3D Highway -> Show FPS
counter) is pinned to the top-right of the highway overlay -- the same
corner the v3 player chrome stacks its persistent Up Next pill and
live-performance HUD into, on a higher layer that paints over the canvas.
So the readout sat behind that chrome and couldn't be read, exactly when a
tester turned it on to judge performance (and because the pill is default-on
it covered the counter regardless of the separate "Up Next won't turn off"
report).
Keep it top-right (where testers look) but drop it just below whichever of
that chrome is showing: measure the lowest visible top-right v3 HUD element
(#v3-upnext / #v3-live-performance-hud / #hud-time) and floor the FPS box's
Y beneath it. Element refs are resolved once and cached (no per-frame
querySelector, per the plugin perf rules) and only read while the counter is
actually drawn; gated on window.feedBack.uiVersion === 'v3' so classic v2 is
byte-for-byte unaffected. Bump plugin version 3.30.0 -> 3.30.1 (the screen.js
cache-buster).
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(highway_3d): hit-feedback juice — cinematic lighting, strike line, sparks, intensity dial
Charrette wave 1 (additive, default-tasteful, all behind settings):
- #8 Hit-feedback settings: hitFx (0..1), cinematic, verdictMarks, timingFx,
streakFx in BG_DEFAULTS + h3dBgSet* setters + settings.html (intensity slider +
cinematic toggle). hitFx=0 → colour verdict only.
- #2 Cinematic lighting: ambient 0.85→0.35 + stronger key light when cinematic on,
so emissive gems have a dark surround to pop against. Live-toggleable.
- #1 Strike line: a glowing bar at the hit line (Z=0) that flashes green on a
verified hit / red on a miss, eased from the per-frame verdict alpha.
- #3 Hit sparks: a pooled additive Points burst at the gem on a verified hit
(deduped one burst per note), scaled by hitFx; disposed on teardown.
Staged for wave 2 (after dogfooding): bloom+ACES (#4), colorblind verdict glyphs
(#6), early/late timing tint (#5), streak heat + clean-bar (#7), gem scale-punch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* feat(highway_3d): wave 2 — gem scale-punch, streak heat, colorblind verdict marks
- #3 (completion) gem scale-punch: the hit gem briefly grows (1 + 0.22·hitFx·alpha),
biggest at the strike and easing with the verdict — the per-gem impulse.
- #7 streak heat: a renderer-side consecutive-hit counter eases a 0..1 "heat"
(plateau at 16) that grows the spark burst + warms the strike-line idle glow;
a miss eases it back down. Behind the Streak-feedback toggle.
- #6 colorblind verdict marks: a redundant ✓ (hit) / ✗ (miss) glyph on the verdict
via the existing 2D label overlay, so the green/red pair isn't the only signal —
notably also covers the provider path (where the timing labels don't show).
- settings.html: Streak-feedback + Accessible-marks toggles.
Deferred: #4 bloom+ACES (needs the Three.js postprocessing addons vendored into
core static/vendor/three/ — not present; warrants its own infra change), and #5's
timing tint (the early/late ±ms labels already render on the event path; surfacing
them on the provider path needs a notedetect verdict field — a cross-plugin item).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* feat(highway_3d): #4 bloom + ACES — vendored Three.js postprocessing, perf-gated
The single biggest fidelity lever from the charrette. Core had only
three.module.min.js (no postprocessing addons), so this vendors the r170
EffectComposer/RenderPass/UnrealBloomPass/OutputPass + their shader deps into
static/vendor/three/addons/, with every `from 'three'` rewritten to the SAME
vendored three (../../three.module.min.js) so the addons share the plugin's
three instance (a CDN copy would be a second, non-interoperable module).
highway_3d wiring:
- Lazy-loads the addons only when the new `bloom` setting is on (dynamic import),
builds EffectComposer(RenderPass → UnrealBloomPass(strength .65/radius .5/
threshold .82 — high so only emissive gems + the hit flash bloom) → OutputPass).
- Render loop uses composer.render() with ACES tone-mapping when bloom is active,
else the unchanged direct ren.render() with NoToneMapping (bloom-off = today's look).
- Perf-gated: OFF in splitscreen; graceful fallback to direct render if the modules
or composer fail; composer.setSize on canvas resize; disposed on teardown.
- settings.html: "Glow bloom" toggle (default on).
Verified the import chain resolves + renders via a same-origin module-load test
(EffectComposer built + a bloom frame rendered, three r170).
Charrette status: 7/8 (only #5's early/late timing tint remains — a notedetect
verdict-field change, outside the highway).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* feat(highway_3d): #5 early/late timing — colour the hit feedback by timing
Surfaces the detector's timing on every hit (the charrette's last item), fully
highway-side: notedetect already dispatches the judgment (timingState/timingError)
on notedetect:hit/miss, so we carry timingState onto the event mark and tint the
hit's spark burst + the ✓ verdict glyph by it — on-time green, early cyan, late
amber. Gracefully falls back to green when no timing is known (pure-provider path),
so it never invents data. Behind the new "Timing feedback" toggle (default on).
Charrette: 8/8 complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* feat(highway_3d): add a "Hit sparks" on/off toggle (note-hit particles)
The on-hit spark burst (the particle effect that fires the instant
note_detect confirms a hit) could previously only be removed by dragging
Hit-feedback intensity to 0 — which also kills the strike-line flash and
the scale-punch. Add a dedicated "Hit sparks" toggle (default on) under
3D Highway settings, in the hit-feedback group beside the intensity
slider, that gates ONLY the spark particles; the strike flash and colour
verdict are unaffected.
Wired the same way as the sibling juice toggles: a `sparks` boolean in
BG_DEFAULTS, in _BG_BOOL_KEYS, a window.h3dBgSetSparks setter, the
per-instance _sparks state + settings re-read, and a guard on the
_sparkBurst spawn. Reuses existing Tailwind utility classes, so
assets/plugin.css is unchanged; plugin.json version bumped to 3.28.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(highway_3d): act on tester charrette — strike line, fog readability, AA
Addresses the alpha-tester 3D-highway feedback thread via the design panel's
recommendations:
- Strike line (panel rec 1a): now a HIT-ONLY faint "now" line — flashes green
on a confirmed hit, no red miss branch (misses already show at the gem: red
wash + ✗). Moved off the bottom edge to the vertical CENTRE of the string
field, which was the "incorrectly placed" complaint (it read as the board's
lower border and fused with open-string gems on a miss). Added a "Strike
line" on/off toggle (`strikeLine`, default on).
- Horizon readability (#2): the note gems + their outlines are now fog-exempt
(`material.fog = false` on mStr/mGlow/mStrHitOutline/mHitBright/mWhiteOutline/
mMissOutline), so upcoming notes punch through the distance fog and stay
legible as they render in — the board, lane, sustains and scenery keep their
atmospheric fog, so depth is preserved.
- Cinematic lighting softened: cinematic ambient 0.35 -> 0.45 so the dark stage
doesn't crush note/fret legibility.
- Anti-aliasing under bloom (perf rec): give the bloom EffectComposer a
multisampled (WebGL2 MSAA x4) HalfFloat render target. The default target had
no `samples`, so bloom-on bypassed MSAA — the "too HD / jagged on Windows,
fine on Mac" report (Mac only won via Retina supersampling). This is the
highest-value, smallest fix for the jaggies.
plugin.json -> 3.29.0. The renderScale quality-oscillation is core
(static/highway.js) and will be a separate feedBack PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(highway_3d): remove the strike line; sparks-only hit feedback, subtler
Second tester-charrette pass. The strike line (even hit-only/centred from the
last pass) was still too distracting/confusing on a hit, so it's removed
entirely — strings + fret markers already orient the player, and the hit is
fully carried at the gem (bright outline + scale-punch + spark burst) with the
timing-coloured ✓/✗ verdict as the knowledge-of-results channel.
- Deleted the strike-line mesh, its per-frame update, the `strikeLine` setting
(BG_DEFAULTS / _BG_BOOL_KEYS / setter / settings-load), the settings.html
toggle, and the now-dead `_strikeLine`/`_ndHitFlash`/`_ndMissFlash` state +
their verdict-block feeds.
- Made the spark burst subtler now that it's the sole celebration: point size
1.7→1.0·K, opacity 0.95→0.8, burst count (7+13·hitFx)→(4+7·hitFx), radial
speed (7+r·20)→(5+r·12)·K, life (0.40+r·0.28)→(0.30+r·0.16)s.
- Toggles for Hit sparks and the ✓/✗ verdict marks already exist in settings
(kept).
Minimal hit-feedback set now: gem bright + subtle spark (celebration) +
timing-coloured ✓/✗ (the KR) + ambient streak heat. plugin.json -> 3.30.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(highway_3d): hydrate hit-feedback settings controls from saved state
The 7 new juice controls (Hit sparks, Cinematic, Streak, Verdict marks,
Bloom, Timing, Hit-feedback intensity) were hard-coded to their default
markup and never read back from localStorage when the settings panel
reopened — so a saved non-default (e.g. Hit sparks off) showed as the
default (checked) even though the renderer correctly honored it. The
sibling controls in the same panel were already hydrated; this restores
that pattern for the new ones.
Reads h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' coercion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Addresses the altitude finding from the Butterchurn review: the visualizer's
on/off + slider options shipped as a parallel UI (a ~140-line floating
in-canvas control panel) separate from the plugin's standard settings panel.
Move the standard controls (Background on, opacity, dim-behind-lane + strength,
chart accents + strength, color tint + strength, guitar gain, song gain) into
settings.html, using the plugin's normal settings UI. They persist into the
same 'viz3d_settings' blob the controller already reads; a new module-scope
window.h3dBcApplySettings() hook lets settings.html push changes to a mounted
highway live (it invalidates the controller's settings cache and re-applies).
The in-canvas panel is now ONLY the live preset browser (pick / favorite /
ban / cycle / hold / meters) — things that are inherently live tools and don't
belong in a static settings form. cyclePool/hold and the favorites/bans lists
stay there; reads were made cache-safe (read fresh via _bcLoadSettings) so a
settings.html write can't be clobbered by a stale captured reference.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Butterchurn control panel is a singleton, created only when a controller
is created and parented to that controller's wrap. In splitscreen the panel
followed the last-created controller; when that controller was torn down,
destroy() only removed the panel DOM if it was the LAST controller, so with
another highway still alive the panel stayed orphaned on the destroyed wrap
and the surviving highway was left with no visualizer controls.
Track each controller's wrap (ctrl.wrap) and, on destroy with another
controller still alive, re-home the panel+pane onto the surviving primary's
wrap via _bcEnsurePanel (which moves them when connected, or rebuilds them on
the survivor if the old wrap was already detached).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(highway_3d): add Butterchurn visualizer background style
Adds an opt-in "Butterchurn (visualizer)" option to the 3D Highway plugin's
Background-style dropdown. When selected, the highway renders over a WebGL
MilkDrop (Butterchurn) canvas that reacts to your playing (guitar input on
desktop, the song <audio> spectrum in the browser) and the chart (beat/note/
chord accents + instrument-color tint). The default stays 'particles', so
existing users see no change until they pick it.
Integrates the standalone "3D Highway + Butterchurn" mod into the bundled
renderer as the 'butterchurn' bg-style (not a fork):
- a self-contained _bc* controller that lazy-loads the vendored butterchurn
libs only when the style is selected; mount/unmount is driven idempotently
by the existing bg-style lifecycle (_bcSyncMode in _bgMountStyle) plus an
explicit teardown in destroy()
- the renderer uses alpha:true with the transparent clear gated on the mode,
so every other bg style stays byte-identical (opaque clear)
- the fog-scenery <audio> tap is disabled while active to avoid a double
createMediaElementSource on #audio
- the mod's slopsmith* globals are adapted to the current feedBack* names and
the vendored asset URLs repointed to /api/plugins/highway_3d/assets/
Vendors butterchurn.min.js + butterchurnPresets.min.js (MIT) + viz-worklet.js
under assets/vendor/; see plugins/highway_3d/NOTICE for attribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* fix(highway_3d): anchor Butterchurn panel to the highway, centered
Addresses three issues found testing the visualizer control panel:
- Attach the panel + preset pane to the 3D highway's `wrap` (position:absolute,
pointer-events:auto) instead of position:fixed on document.body, so they sit
on the highway's right edge and only exist while the highway is on-screen
(no longer linger on the main menu / float at the app edge).
- Re-home the singleton panel to the active highway wrap on mount, so it follows
whichever highway is showing (e.g. moves off Virtuoso's embedded highway onto a
normal song's highway) instead of sticking to the first one created.
- Center it vertically (top:50% + translateY(-50%), folded into the slide
transform) so a top overlay element no longer covers it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* fix(highway_3d): harden Butterchurn audio + lifecycle (review #597)
Browser audio reactivity now REUSES the highway's existing shared analyser
(the fog scenery's #audio / stems side-chain tap) instead of opening a
second createMediaElementSource on #audio. The old _bcBrowserSource path:
- threw InvalidStateError when the fog tap already owned #audio (default
config), leaving the visualizer non-reactive in the browser, and could
permanently disable fog reactivity if it tapped first (one-shot/element);
- rerouted the song through a fresh, possibly-suspended AudioContext, which
could MUTE playback when butterchurn was selected mid-song;
- ignored the stems analyser, so it saw only silence on sloppak songs.
_bcCreateController now takes an audioProvider (wired to _bgGetAnalyser) and
connectAudio()s the shared AnalyserNode (a passthrough, so the fog's own
reads are undisturbed).
Also:
- destroy() now closes the AudioContext when we own it (desktop / browser
fallback), fixing a per-mount leak that hit the browser ~6-context cap
after a few style toggles. The shared (fog-owned) context is never closed.
- _bgApplyVenueSceneFog keeps the clear transparent while butterchurn is
active, so the venue scene no longer occludes the visualizer.
- _bcLoadLib no longer caches a rejected promise, so a transient vendor-load
failure can be retried instead of disabling the feature for the session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(highway_3d): Butterchurn lifecycle + audio re-bind (Codex preflight)
Local Codex preflight on the Butterchurn feature flagged four issues; all fixed:
- WebGL context leak on teardown: destroy() (and the async-init failure path)
now call _bcReleaseCanvasGL() to force WEBGL_lose_context before dropping the
canvas, so repeated mount/toggle cycles can't exhaust the browser's WebGL
context cap.
- Stale shared analyser across songs: the browser path captured the analyser
once at mount, so a sloppak stems swap (new analyser, often new context) left
the visualizer reacting to a dead node. update() now compares the live
_bgGetAnalyser() against what the controller actually bound (boundAnalyser(),
guarded by ready()) and either reconnects (same context) or rebuilds the
controller (context changed) via the proven destroy()+_bcSyncMode paths.
- Half-mounted controller on createVisualizer failure: the async .catch now
cleans up (closes an owned AudioContext, removes layers, marks dead) and
_bcSyncMode retries when bcCtrl.dead(), instead of leaking and never recovering.
- _bcFfIdx off-by-one dropped accents landing exactly on a seek/loop target
time; it now uses strict < so the update walkers fire the boundary event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(highway_3d): add one-click string-color presets
Adds 12 named string-color presets (Warm→Cool, Vivid, Colorblind-friendly,
Neon, Accessible, Warm Ember, Tape Deck, CRT Green/Amber, Pitch Ramp, Sunrise)
selectable from the 3D Highway settings panel.
Extends the existing core HWC (highway-color) subsystem in static/app.js with
HWC_PRESETS + applyHighwayStringPreset(), exposed on the existing facade as
window.feedBack.highwayColors.{presets, applyPreset}. The plugin settings page
renders the preset buttons from that core list and refreshes the per-string
pickers on apply. Purely additive — stock behavior is unchanged.
Scope: core static/app.js (the shared HWC facade both highways consume) plus the
highway_3d plugin's settings.html / screen.js / CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq
* fix(highway_3d): address review of colour-theming PR
- Rebuild assets/plugin.css so the new `flex-wrap` (preset row) and
`text-[10px]` (theme-dropdown helper) Tailwind classes are actually
compiled, and bump plugin.json 3.26.0 -> 3.27.0 so the <link>'s ?v=
cache-buster fetches the fresh CSS (per the plugin's build rule).
- Replace the mirror-at-every-read hwTheme migration with a one-time
backfill (persist hwTheme := bgTheme on first load, no emit). The two
scene-color axes are now genuinely independent: changing the Background
dropdown no longer silently retints the Highway surface/lane, and the
rendered highway can't disagree with the Highway dropdown value.
- Collapse the duplicated theme id-set in settings.html (two identical
<option> lists + VALID_BG_THEMES) into a single SCENE_THEMES source the
dropdowns and validator are generated from; sync points 4 -> 2.
- Update CLAUDE.md to document the backfill + reduced sync contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Replace the single long scrolling v3 settings screen with a horizontal tab
bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins /
System) over card rows (icon + title + description, control on the right) with
a per-category Reset.
- static/v3/index.html: tab bar + card-row markup (ids keep hydrating through
the unchanged app.js loadSettings()/persistSetting() path).
- static/v3/settings.js (new): tab switching + active-tab persistence
(localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds
reference from window.getAllShortcuts().
- static/v3/v3.css: plain CSS, no Tailwind rebuild.
- Per-plugin settings tab: new optional settings.category in plugin.json →
plugins/__init__.py surfaces settings_category; app.js mounts each plugin
<details> into #plugin-settings-<category> (fallback: Plugins tab).
highway_3d ships category: "graphics".
- New gameplay settings: countdown_before_song (wired end-to-end, default off);
miss_penalty + fail_behavior (persist-only stubs); "Note highway speed"
surfaces existing master_difficulty.
- New POST /api/settings/reset clears whitelisted keys back to defaults.
Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest,
tests/browser/settings-tabbed.spec.ts. 179 passed locally.
Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main
(slopsmith→feedBack rename applied; settings-screen markup conflict resolved
in favour of the new tabbed layout — all prior setting ids preserved).
Closes#579
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Update GitHub repo references from feedback* to feedBack*
* rename: slopsmith -> feedBack, byron -> got-feedBack
Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias
Refs: #rename-slopsmith
* rename: complete regen against current main + fix backward-compat alias
Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).
Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
resolution, and move the bus aliases to AFTER the _feedBackExisting merge
block so they reference the fully-assembled object (also fixes the
loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
source labels.
Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* rename: implement advertised backward-compat + prune dead community plugins
Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.
Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
(_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
`FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
`FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).
Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
and clear the legacy key on write — so a user's update-channel preference
survives the rename instead of resetting to "stable".
Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).
Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching between the 3D drum highway (renders onto #highway) and the 3D
guitar highway (renders into its own .h3d-wrap overlay) left the previous
drum frame showing through the gap the overlay did not cover.
Core: _setRenderer now replaces #highway on a genuine viz change (keyed on
viz id via _rendererVizKey, not object identity, so benign same-viz
re-installs don't churn the canvas) as well as on a context-type change.
highway_3d: applySize pins the .h3d-wrap overlay to #highway's exact box,
derived from the same getBoundingClientRect measurements that size the
renderer (sub-pixel correct under zoom). Re-pins once the canvas lays out
(init race) and resets to the static anchor in the not-laid-out fallback.
Reviewed locally via codex (5 rounds, converged clean). CI checks are the
known org Actions billing block, not real failures.
window.h3dSetFretSpacing was the only 3D-highway setting that applied via
location.reload(). The SPA boots with #home as the active screen and has
no restore-last-screen mechanism, so the reload ejected the user from
Settings onto the home screen.
Apply it live like every other 3D-highway setting: rebind the module-scope
_h3dFretUniform flag (so panels mounted later this session pick up the new
mode), recompute the two fretX-derived scalars baked at init
(_fretLabelScaleRefW, FRET_WIDTH_MID), and broadcast a 'fretSpacing' change
over the existing _bgEmitChange pub-sub so every mounted panel rebuilds its
board via buildBoard(). Per-frame note geometry already reads fretX live.
Settings copy updated (no longer reloads) and tests/js pin the no-reload /
live-rebuild behavior.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the voicing/fn.rn teaching-mark render for the two new chord-template
fields, in both the 2D and 3D highways:
- Extend the shared pure chordHarmonyLabels() helper (identical in static/highway.js
and plugins/highway_3d/screen.js) to also surface caged ("CAGED: E") and
guideTones ("gt 4,10"), pre-formatted and node-testable. Invalid caged enum and
out-of-range / non-int guide tones are filtered out.
- Draw both, stacked above the existing rn/voicing labels, in distinct colors.
- Gated behind the SAME teaching-marks toggle (_showTeachingMarks 2D /
teachingMarksVisible 3D) — no clutter on the default highway.
Render only — no scoring / NoteVerifier coupling.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(core): carry chord harmony fn + template voicing on the wire (§6.3.1, §6.6)
Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the
teaching-marks (fg/ch/sd) wire work:
- Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent.
Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range
fn (which would fail the schema's required-keys rule) never rides the wire.
Default-omitted, mirroring bend bnv.
- ChordTemplate.voicing (template): key-independent voicing-type string
("open", "triad", "shell", "drop2", "barre", ...). Emitted only when
non-empty; non-string wire values fall back to "".
Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation
is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a
deg-only fn would be schema-invalid, so server.py carries author-provided fn
unchanged. GP import unchanged (no reliable per-chord function/voicing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(highway): render chord harmony fn.rn + voicing on 2D + 3D (§6.3.1, §6.6)
Draw the chord's harmonic-function Roman numeral (instance fn.rn) and its
template voicing string, stacked above the chord name on both highways. A shared
pure helper chordHarmonyLabels(fn, voicing) formats the two labels (empty when
absent/malformed) and is node-tested against both files.
Both labels are gated behind the EXISTING teaching-marks opt-in
(_showTeachingMarks / teachingMarksVisible bundle flag) — they're chord-level
teaching overlays, same class as sd/ch, so they stay off the default highway.
2D guards the empty-note-chord case; 3D reuses the gold chord-label sprite style.
Render only — no scoring / NoteVerifier path is touched (honesty rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge review of #538 noted the fg finger numeral rendered unconditionally on
both highways and couldn't be turned off — only sd/ch sat behind the (default-off)
teaching-marks toggle. A user who finds per-note numerals busy had no way to
declutter.
Add a SEPARATE finger-hints gate that keeps fg shown by default but makes it
hideable, independent of the sd/ch opt-in (so the two defaults — fg on, sd/ch off —
coexist; a single boolean can't express that):
- 2D static/highway.js: _showFingerHints (localStorage 'showFingerHints' !==
'false', i.e. default on), a fingerHintsVisible bundle flag, and
get/toggle/setFingerHintsVisible API; gates the fg label.
- 3D plugins/highway_3d/screen.js: mirrors via bundle.fingerHintsVisible !== false
(default on); gates the fg sprite. sd/ch unchanged.
Default-on preserved (absent localStorage / absent bundle flag => shown); only an
explicit false hides fg. Codex-reviewed: clean. Render test 7/7.
Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render the three per-note teaching marks on both highways, mirroring the
bend-curve render (#532). Display only — no scoring / NoteVerifier coupling.
- 2D static/highway.js: fg renders by default as a small finger numeral hugging
the gem (T = thumb, 1..4); sd (degree label) and ch (strum bracket connecting
notes that share a ch key, arrow direction from pkd) are opt-in behind a new
`showTeachingMarks` toggle (exposed via toggle/get/set + the bundle's
`teachingMarksVisible` flag). Pure helpers teachingFingerLabel /
teachingDegreeLabel / strumGroupBuckets drive the glyphs. ch bracket is
note-stream-only (chord notes already read as one gesture).
- 3D plugins/highway_3d/screen.js: fg (default) + sd (opt-in, mirrors the 2D
toggle via bundle.teachingMarksVisible) render next to the per-note fret label
via a new pooled sprite (pTeachMarkLbl); _scrChordNote resets fg/sd so chord
notes don't inherit stale marks. ch strum brackets are deferred in 3D (no
cross-note batch pass in the per-note render); 2D covers ch.
Tests: tests/js/highway_teaching_marks.test.js extracts the pure helpers from
both files (extract-and-eval) and asserts label mapping + strum-group bucketing.
Part of got-feedback/feedback#334
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-merge Codex review of the bend-curve PRs (#531/#532) surfaced edge cases:
- GP8 (#531 P2): bnv timing used rn.sustain, which is zeroed for notes <= 0.2s,
so short GP8 bends kept the scalar bn but lost bt/bnv. Use the beat duration
`dur` (matching the GP5 path) so the curve survives.
- 2D highway (#532 P2): bnvNormalizedPoints mapped x over the curve's own t-range
[first,last] instead of the note span, so curves not starting at 0 / ending at
sus were time-distorted. Now maps over [0, sus] (clamped), with a curve-span
fallback when sus<=0 (existing no-sus callers unaffected).
- 3D highway (#532 P3): the sustain ribbon + bend chevron were gated on bn>0, so a
note carrying an authoritative bnv with bn==0 drew no ribbon/marker. Both now
also fire on bnv presence; chevron steps derived from max(bn, bnv peak).
Codex-reviewed: clean (no findings). +1 JS test (sus-relative mapping + fallback).
JS 8/8, 250 core GP/song tests pass.
NB: GP8's short-bend path still lacks a dedicated synthetic-GPIF fixture (same gap
as the GP8 offset-prop-names P3) — _gpx_bend_shape units cover the function; the
fix is the one-line caller change.
Part of got-feedback/feedback#334.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-B of the bend-shape feature (feedpak §6.2.1). Both highways drew a bend
from the scalar `bn` only; now they trace the authoritative `bnv` curve
([{t, v}]) when present and fall back to the `bn` arc/envelope otherwise.
2D (static/highway.js drawNote): when a note carries `bnv`, draw the real
shape as a contour above the gem (round-trip rises then falls, pre-bend
starts high, release descends — `bt` is implicit in the point shape), with
an arrowhead only when the gesture ends rising. `bnvNormalizedPoints` maps
{t,v} to a 0..1 x span. The scalar-arrow path is preserved unchanged as the
fallback; the peak label is unchanged.
3D (plugins/highway_3d/screen.js): `bnvSampleAt` linearly interpolates the
curve (clamped to its endpoints) and `bendSemisAtTime` samples it when
present, else keeps the synthetic rise→hold→release envelope from `bn`. The
chevron count still comes from the peak. Fixed a stale-scratch hazard: the
reused `_scrChordNote` now resets `bnv`/`bt` (omit-when-default) after
Object.assign, mirroring the existing `fhm` reset, so a chord note without a
curve can't inherit the previous note's contour.
Render-only — no wire/schema change. Pure helpers covered by
tests/js/highway_bend_curve.test.js (interp, clamping, round-trip,
degenerate/empty); node --check passes on both files; full tests/js green.
Part of got-feedback/feedback#334
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:
- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs
No functional change beyond the renamed identifiers; all Python compiles.