Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Sonnet 4.6 ffbbf3fb94 fix(highway_3d): hoist sY before createScoreFx to resolve TDZ
Root cause: const sY was declared 371 lines after createScoreFx({..., sY})
inside createFactory(). The shorthand {sY} reads the binding immediately
(not a closure), so every factory call threw ReferenceError: Cannot access
'sY' before initialization. highway.js caught it, reverted to 2D, emitted
viz:reverted. THREE.js was never requested.

Fix: hoist sY declaration to just before createScoreFx. Also hoist
_invertedCached, nStr, curX, and highwayCanvas above their first lexical
reference (all were closure false positives, but hoisting makes the code
unambiguously safe and keeps the new ESLint gate clean with 0 errors).
Hoist _bcPanel in bc-panel.js for the same reason.

Regression gate: eslint no-use-before-define (variables:true, functions:false)
scoped over plugins/highway_3d/ (screen.js + src/). Statically catches any
const/let used before its declaration in the factory — the whole class, not
just this pair. RED at broken tip (sY flagged): GREEN after fix.
Pre-existing violations surfaced (all closure false positives, none true TDZ
runtime bugs): highwayCanvas in _v3TopRightChromeBottom body, _invertedCached
and nStr in sY arrow body and DI getters, curX in getCurX DI getter, _bcPanel
in bc-panel.js function bodies — all resolved by hoisting; no silenced errors.

Bump plugin.json 3.53.0→3.54.0 (viz factory change per standing rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 12:58:19 +02:00
byrongamatosandClaude Sonnet 4.6 a6efd4ee5e refactor(h3d-carve-17): cut-17 — alias removal + stale doc comment cleanup
Cleanups:
- scene-init.js: remove dead DI alias getHighwayCanvas:_getHighwayCanvasAlias (Creed note)
  setHighwayCanvas now correctly gains its own line in the DI block
- note-renderer.js: two stale line-number refs → module-relative refs (Toby r4 LOW)
- arp.js: stale 'at line 4025' doc comment → 'factory-scope fn in screen.js'

Gates:
- ESLint: 0 errors on scene-init.js
- plugin.json: 3.52.0 → 3.53.0
- Tests: 448/448 (alias kill test added; DI count repinned 178→179; version test updated)
  Note: DI count is 179 post-edit (setHighwayCanvas promoted to line-first position)

CAF sweep: waived (no setter-arg changes in scope)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 07:57:08 +02:00
byrongamatosandClaude Sonnet 4.6 6c15ed9dda fix(h3d-carve-16): Toby r1 — F1 ternary, F2 dead DI, F3 naming guard, F4 smoke honesty
F1 (HIGH): Fix truncated-ternary in _bgLoadSettings — setZoomSmoothing and
setTiltSmoothing were closing before the ternary, storing the boolean
_bgHasStored(...) result instead of the camera-smoothing value. Move
closing ) to after : _cameraSmoothing. Add kill tests for both setters
asserting the argument contains '?'.

F2 (MED): Remove 13 dead DI params (5 lines) from createSceneInit signature
and matching entries from screen.js wiring:
  - FRET_WIRE_HIT_OP / HIT_INTENSITY / HIT_DECAY (renderer.js only)
  - updateStringHighlights (declared null, never called)
  - getIsDestroyed (screen.js lifecycle flag, not scene-init's concern)
  - setChartEnv/PrevT, setBcBeatIdx/NoteIdx/ChordIdx (BC chart-sync: per-frame)
  - setTintR/G/B (BC tint: per-frame, managed outside scene-init)
DI count repinned: 183 → 178.

F3 (LOW): Add naming-correspondence guard — for every getX in the DI,
assert a matching setX exists unless getX is in READ_ONLY (6 pinned
stable-ref getters that scene-init never writes).

F4 (honesty): Rename smoke test — was 'setters called before null-T throw'
(wrong: null canvas returns before T is accessed, no setter is called).
Now: 'factory construction + null-canvas early guard'.
Remove dead di stub entries (FRET_WIRE_HIT_*, getIsDestroyed,
updateStringHighlights) that no longer exist in the DI signature.

Suite: 427/427 (h3d glob).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 07:21:01 +02:00
byrongamatosandClaude Sonnet 4.6 a0beb67b0d test(highway_3d): cut-16 scene-init — setter-call kill tests + execution smoke + pinned DI count
§10 kill tests added (10 source-scan assertions):
- initScene body calls setRen(, setScene(, setPNote(, setWrap(
- buildBoard body calls setBoardStringStartX(, setFretWireMats(
- _bgLoadSettings body calls setActivePalette(, setCameraMode(, setGlowMul(
- _bcSyncMode body calls setBcCtrl(
Each kills silently when its setter call is gutted — catches omission not
covered by the class-killer (which only catches assignment to DI param names).

§11 gate-2 execution smoke: new-Function harness with Proxy-based recording
DI stubs. With null canvas, initScene() returns false immediately (early
guard path). Documents WebGL gap: T.WebGLRenderer requires a real canvas;
source-scan kill tests cover the allocation paths. Sloppy-mode write gap
documented; ESLint no-undef compensates.

Anti-vacuity test updated: floor ≥150 kept; exact pinned count 183 added
(line-first-identifier extractor; comma-split total ~397 because many lines
pack pairs like 'getX, setX,' — both are counted in the comma-split).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 06:52:36 +02:00
byrongamatosandClaude Sonnet 4.6 273412744a fix(h3d): cut-16 follow-up — tests, plugin.json bump, visibility/string-colors retargets
- Add tests/js/highway_3d_scene_init.test.js (18 tests): wiring guard,
  export surface, class-killer, DI anti-vacuity, import correctness,
  function presence, kill tests, plugin.json version gate.
- Bump plugins/highway_3d/plugin.json 3.51.0 → 3.52.0.
- Retarget highway_string_colors.test.js tests 3–4 to scene-init.js
  (functions moved in cut-16).
- Update highway_visibility.test.js test 22 regexes to accept DI-rewritten
  forms: getHighwayCanvas() / getWrap().style.display / getHighwayCanvas().offsetParent.

Suite: 1426/1427 pass (1 pre-existing audio test; all cut-16 regressions cleared).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 06:46:08 +02:00
byrongamatosandClaude Sonnet 4.6 4bb5d21a40 refactor(highway_3d): h3d-carve cut-16 — move initScene + bg-helpers + buildBoard to src/scene-init.js
VERBATIM-MOVE: initScene() (4215-5704), bg-helper functions (5706-6221), and
buildBoard() (6242-6571) extracted from screen.js into a new ES module
src/scene-init.js as createSceneInit({...~200 DI params}) → { initScene,
buildBoard, _bgUnmountStyle, _bcSyncMode }.

All factory-scope variables that other modules read go through getter+setter
pairs (no plain-value shorthands) to avoid the fork-class bug from cut-15.
Local alias pattern applied throughout: const _x = new T.Thing(); setX(_x);
closures use getX() to read current value.

Test retargets: highway_3d_context_loss, fret_spacing, pool_warm,
render_order, score_fx, sustain_bloom, sustain_rail, wide_fov — all updated
to search src/scene-init.js alongside screen.js, and patterns broadened for
DI forms (getRen().domElement, getFretG(), getFretTubeGeo(), etc.).

414/414 tests pass. ESLint: scene-init.js 0 errors, 1 max-lines warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 06:29:27 +02:00
byrongamatosandClaude Sonnet 4.6 b7e36cc633 fix(h3d): Creed re-check — shared-mutable-state setter pairs for 6 draw vars
THE BUG (silent, no throw): _drawNextByString, _drawRecentByString,
_drawChordTemplates, _drawAnchors, _drawTeachingMarks, _showFingerHints
were passed as plain-value shorthands to createRenderer. update() wrote
to those parameter locals; createNoteRenderer's getters read the original
screen.js closure vars — which never updated. drawNote saw stale null/false
on every frame.

THE FIX: converted all 6 to getter+setter DI pairs. update() calls
setDrawX(value); createNoteRenderer's existing get*() closures read
the same screen.js let vars. One store, no fork.

CLASS-KILLER GUARD (test 24): extracts all DI param names from the
createRenderer signature; scans module body (comments stripped) for
assignment operators on those names; asserts ZERO. RED at a55dca7
(6 assignments); GREEN here.

KILL TEST (test 28): overrides the 6 setter stubs in _makeDI() with
real backing-store vars; runs update() with a future note; asserts
backing store mutated from null sentinel. RED at a55dca7 (plain
assignment never called the setter; store stayed null). GREEN here.

ALSO (Toby r4 LOW): corrected smoke-test comment — reading an undeclared
variable throws ReferenceError in BOTH strict and sloppy mode; only WRITING
to undeclared differs (sloppy creates a global). The new Function sloppy
hole is for writes-only, not reads.

DI count: 321 (was 315, -6 shorthands +6 getters +6 setters).
Suite: 1408/1409 (test 46 pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 01:35:59 +02:00
byrongamatosandClaude Sonnet 4.6 a55dca7893 fix(h3d): Toby r3 — restore _CV_KEY_TIME to screen.js, fix test-16 regex vacuity
F1 (HIGH): _CV_KEY_TIME_MUL / _CV_KEY_TIME_SLOT were moved into the
renderer.js closure at 06e4fe3 but _encodeChordVerdictKey (defined in
screen.js IIFE scope, DI'd as fn-ref) still reads them from screen.js
scope → ReferenceError on first chord-template chart frame. Fix:
restore both consts to screen.js (declared before _encodeChordVerdictKey),
add them as DI shorthands in renderer.js, remove closure copies. Single
source of truth — no dual definitions.

Kill test 23: asserts _CV_KEY_TIME_MUL / _SLOT are declared in screen.js
before _encodeChordVerdictKey. RED at 06e4fe3, GREEN here.

F2 (MED): test #16 createRenderer regex /const \{ update \} = .../ did not
match after F1-prewarm added _prewarmStatic/_prewarmChart to the destructure
→ vacuous pass (wiring block never extracted, 0 shorthands checked). Fix:
updated regex to /const \{[^}]*update[^}]*\} = createRenderer\({...}\)/.
Added anti-vacuity floor: assert extracted Set.size >= 150 so a future
regex break fails loudly.

P3 (LOW): added ⚠ comment on smoke tests 23-25 documenting the new Function
sloppy-mode hole and naming eslint no-undef as the compensating layer.

DI count: 313 → 315 (+2 shorthands for _CV_KEY_TIME_MUL / _CV_KEY_TIME_SLOT).
Suite: 1406/1407 (test 46 pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 01:16:13 +02:00
byrongamatosandClaude Sonnet 4.6 06e4fe335a fix(h3d): carve-15 full DI — ESLint no-undef=0 on renderer.js
Resolve all 160 undeclared names that were latent ReferenceErrors in the
ES-module-scoped renderer.js. Module scope never chains into screen.js's
IIFE scope; every name was a crash on first execution path.

Changes:
- renderer.js: move 44 private names into createRenderer closure (Category E
  no-screen-use); DI 13 shared mutable names as getter/setter pairs; DI 43
  Category-B consts, 37 Category-C fn-refs, 3 Category-D getters (ren/scene/cam),
  11 Category-F stable refs + getter/setter, 5 Category-G lane-material getters;
  2 extra getters for chordFrameGradTex/Arp.
- score-fx.js: add fxClearSeen() to exports so renderer can clear _fxSeen
  without a direct reference.
- screen.js: remove 44 declarations moved to closure; wire all 129 new DI
  params in createRenderer call; destructure fxClearSeen from createScoreFx.
- tests: update DI count pin 184→313; fix 4 test regexes for new API surface;
  add 3 actual execution smoke tests (new Function pattern, no-ReferenceError
  assertion, documents first crash at d475899).

ESLint no-undef: 0 errors on renderer.js.
Suite: 1405/1406 (test 46 pre-existing, unchanged since cut-8).
DI count: 313 (getters=105, setters=48, shorthands=160).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 01:00:02 +02:00
byrongamatosandClaude Sonnet 4.6 d475899c5a fix(h3d-carve-15): resolve Creed r1 F1/F2/F3 + camera import + smoke tests
F1: return { update, _prewarmStatic, _prewarmChart } from createRenderer;
    screen.js destructures all three (callers at :7377/:7461 were getting undefined).

F2: _applyNoteCamTargets read cameraLockLow/cameraLockZoom as free variables;
    replaced with getCameraLockLow() / getCameraLockZoom() getter calls.

F3: add dZ, renderOrderForLayerAtZ to geometry.js import; DI TS, S_BASE,
    FRET_LABEL_GOLD_HEX, FRET_LABEL_IDLE_HEX as shorthands from screen.js.

Camera import fix: camera.js only exports createCamera; remove the broken
    import of lookaheadBootstrapTime/ComputeFretBounds/TargetWorldX from
    camera.js (caused SyntaxError on module load); DI them as shorthands
    from screen.js createCamera() destructure at line 6755.

DI count: 177 → 184 (+7 shorthands).

Tests: 6 new execution-readiness guards (17-22) in highway_3d_renderer.test.js,
    each RED at 7180eff and GREEN at this tip. Suite 1402/1403 (pre-existing #46).

ESLint gate: Creed F1/F2/F3 names cleared; 160 remaining no-undef identifiers
    documented in hive/plans/h3d-cut15-creed-fix.md (categories B-G: deferred
    screen.js consts/fn-refs and renderer-internal closure state — carve-15b).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-06 00:23:32 +02:00
byrongamatosandClaude Sonnet 4.6 7180eff05d fix(h3d-carve-15): resolve Toby r1 F1/F2/F3 + scope-check test
F1 (HIGH): remove 44 phantom constants + fireDrawHooks + fxSpawnPop from
createRenderer DI signature and screen.js wiring. None ever existed in
screen.js scope; the engine threw ReferenceError at plugin init before
any argument was passed to createRenderer.

F2 (HIGH): un-move _setLabelMap back to screen.js (declared before
createNoteRenderer wiring at line ~6631), pass as DI to both
createNoteRenderer and createRenderer. Removing it from screen.js scope
caused a second independent ReferenceError before F1 even fired.

F3 (MED): drop 16 confirmed-dead DI params (0 body occurrences in
renderer.js): S_COL, SLIDE_RIBBON_SAMPLES, drawNotedetectLabels,
drawScoreFx, _resetStringDependentCaches, getCurDist, getCurLookY,
getTgtLookY, getFretRowFitBoost, getNdHitMarks, getNdMissMarks,
getImPMXFillCount, getImPMXLinesCount, getImFHXFillCount,
getImFHXLinesCount, getMeasureStarts. getMeasureStartsRef retained
(1 body use at renderer.js:831).

Scope-check also caught 4 pre-existing phantoms:
- PROJ_WIN, PROJ_WIN_G in createNoteRenderer wiring: note-renderer.js
  body uses hardcoded 0.6 / _PROJ_WIN_ARP; these DI params only appear
  in comments. Removed from note-renderer.js signature and both wiring
  calls.
- camAhead, camTau in createRenderer wiring: update() re-declares both
  as local let vars that shadow any DI value; also not declared in
  screen.js. Removed from renderer.js signature and wiring.

New test (highway_3d_renderer.test.js #16): static wiring-scope check
blanks all four factory wiring blocks from the corpus, then asserts each
shorthand token appears in the remainder. RED at 7623ad8 on BEAT_HEAD_SEC
(44 phantoms), GREEN at this tip.

DI count: 241 → 177 (createRenderer), 130 → 128 (createNoteRenderer).
Suite: 1396/1397 (test 46 pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 23:37:57 +02:00
byrongamatosandClaude Sonnet 4.6 7623ad85e3 feat(h3d-carve-15): extract U-section (per-frame renderer) into src/renderer.js
- createRenderer factory DI: 241 params (consts, fn-refs, 33 pool getters,
  material/settings/camera/ND getters, 35 setters)
- screen.js: tombstone + createRenderer wiring after createNoteRenderer +
  createCamera wirings (§1 ruling: createNoteRenderer called from screen.js)
- Restore createArp wiring + _resetStringDependentCaches to screen.js
  (accidentally dropped during carve; both needed in IIFE scope)
- smoothNow: correction 3 — setter form setFrameNow(v); return v (no bare
  return (_frameNow = raw))
- _applyNoteCamTargets callers: 2 sites (7940/9923); correction 2 verified
- lookaheadSmoothCamStep callers: 3 sites (9963/9974/9978); correction 2 verified
- plugin.json: bump 3.50.0 → 3.51.0

Tests (16 files updated to scan renderer.js):
- highway_3d_renderer.test.js: new, 15 tests — export contract, DI count
  (241), tombstone, caller-list corrections, smoothNow semantics, ordering
- highway_3d_arp_deferral.test.js: add renderer.js scan (deferChordGems /
  noteStreamCoversArpShape moved to renderer.js)
- highway_3d_lean_sustain.test.js: add renderer.js scan; update to
  setLeanSus/getLeanSus() getter form
- highway_3d_smooth_clock_pause.test.js: fix literal-newline syntax error;
  update to setClkAudioT/setClkPerf/setFrameNow setter form;
  update new-sample regex to match getClkAudioT()
- highway_3d_slide_target.test.js: add renderer.js to src scan
- highway_3d_sustain_rail.test.js: assert against rendererSrc (pattern
  moved from U-section)
- highway_chart_transform.test.js: add utils.js scan (_openStringPitchLabels-
  ForTuning moved to src/utils.js by h3d-carve-3)
- highway_note_state.test.js: add renderer.js scan (_ndGetNoteState /
  _ndHasProvider captures in renderer.js update())
- highway_3d_camera_bootstrap.test.js: setter form for camSnapped/curX/
  measureStarts; renderer.js added to scan
- highway_3d_camera_framing.test.js: renderer.js added; setMeasureStarts/
  setCamSnapped setter form in assertions

Suite: 1395/1396 (test 46 pre-existing failure unrelated to carve-15)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 22:47:48 +02:00
byrongamatosandClaude Sonnet 4.6 b02c760aec fix(h3d-carve-14): restore dropped tw arg in tremoloOffsetWorldX call; distinguish mStr/mGlow fakes
Creed F1 (MED): slideRibbonUpdatePositions called tremoloOffsetWorldX(n, Tk) — the
move dropped the tw (ribbon width) argument. Helper signature is (n, chartTime, trailW);
with trailW=undefined every ribbon vertex position was NaN for notes with n.tr truthy
(tremolo slide sustains). Restored verbatim: tremoloOffsetWorldX(n, Tk, tw).

Creed F2 (LOW): _buildDrawNote gave getMStr and getMGlow identical fakeMat objects, so
swapping the wiring left the suite green. Now each string index gets its own named object
(mStr[i]/mGlow[i]); added behavioral assertion that gem core.material === mStrMats[s].

Tests: 26/26 note-renderer pass (+2 new kills: tests 25 and 26). Suite 1379/2 (same
2 pre-existing failures as 59bccef baseline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 21:38:18 +02:00
byrongamatosandClaude Sonnet 4.6 59bccefe00 fix(h3d-carve-14): Toby r1 — 4 findings resolved
F1 (dead DI wiring): remove 7 fn-refs absent from V-section pre-carve:
  xFret, pbBeg, pbEnd, arpeggioChordIdForNoteWithInferCache,
  arpHsBoundsForNote, chordWireHighDensity, muteXMat
  Wiring count 137→130 in both factory signature and screen.js call.

F2 (dead imports): trim geometry.js import to the 3 live symbols:
  dZ, slideTrailEnd, renderOrderForLayerAtZ (drop 8 unused).

F3 (counts coherence): header 136/30→130/24, test name and
  assertion 137→130, all coherent after F1.

F4 (behavioral kill): add two execution-path tests for drawNote via
  new Function sandbox with full DI stubs.
  - Negative: dt=-999 → early exit at line 452 → pNote.get()=0
  - Positive: dt=0 → gem body executes → pNote.get()≥2 (outline+core)
  Kill proof documented in test comments.

Suite: 1377 pass / 2 fail (same 2 pre-existing as c58c40f baseline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 21:17:27 +02:00
byrongamatosandClaude Sonnet 4.6 c58c40f1ed feat(h3d-carve-14): extract V-section (note renderer) into src/note-renderer.js
Carve cut 14 of the h3d-carve epic. Moves the full V-section (note renderer):
  drawNote, drawArpBrackets, drawNotedetectLabels, chordHarmonyLabels
  and 14 private helpers (~1,433 lines)
from screen.js into plugins/highway_3d/src/note-renderer.js
as a factory-DI ES module: createNoteRenderer({137 DI params}).

Beyond-subst (3 sites):
  _ndVerdictSawAlpha = true  → setNdVerdictSawAlpha(true)
  _ndVerdictMaxAlpha = v     → setNdVerdictMaxAlpha(v)
  _streakHits = 0/++         → setStreakHits(0/getStreakHits()+1)

Tests:
  - New: tests/js/highway_3d_note_renderer.test.js (22 assertions)
    wiring guard (137 params), export contract, chordHarmonyLabels
    behavioral kills, beyond-subst sentinel, tombstone checks
  - Updated 8 existing test files to search note-renderer.js
    alongside screen.js for moved patterns (h3d-carve-14 retarget)

Bumps plugin.json 3.49.0 → 3.50.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 20:32:15 +02:00
byrongamatosandClaude Sonnet 4.6 66aa829362 h3d-carve-13: extract S-section lookahead helpers into src/camera.js
Partial cut (Option A) per contract ~/Feedback-harness/hive/plans/h3d-cut13-contract.md.

What moves:
- lookaheadEndTime (factory-private) — 1 beyond-subst: _measureStarts → getMeasureStarts()
- lookaheadBootstrapTime (exported)
- lookaheadComputeFretBounds (exported) — uses lowerBoundT from geometry.js (new import)
- lookaheadTargetWorldX (exported)

DI delta: +9 params to createCamera()
  Constants (+4): NFRETS=24, CAM_LOOKAHEAD_MEASURES=9, CAM_LOOKAHEAD_SEC=3.0, CAM_FRET_EDGE_BLEND=0.1
  Getter  (+1): getMeasureStarts: () => _measureStarts
  Fn refs (+4): validString, getChartAnchorAt, xFretMid, xFret
Total createCamera() DI: 57 (was 48)

Deferred to cut 15 (U-section entanglement):
  lookaheadSmoothCamStep, _applyNoteCamTargets — write S-section state vars
  Region B (per-frame cam target block, ~90 lines) — reads 8 update() locals
  Region C (song-change + bootstrap block, ~174 lines) — U-section init code

Call sites unchanged (destructured names unchanged):
  lookaheadComputeFretBounds: lines 7474, 7941
  lookaheadBootstrapTime: line 7939
  lookaheadTargetWorldX: lines 7943, 10062

Tests: 17 new (highway_3d_camera_lookahead.test.js), 2 retargeted to cameraSrc.
Suite: 1353/1355 pass (2 pre-existing failures in unrelated test files).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 19:19:57 +02:00
byrongamatosandClaude Sonnet 4.6 e230629770 h3d-carve-12: extract T-section (arpeggio inference) into src/arp.js
Move all ~770-line T-section functions (chordWireHighDensity, chordTemplateLabel,
chordTemplateMarkedArpeggio, chordHandShapeArpeggioHint, mergeHandShapeSynthChords,
mergeChordShape, inferArpeggioFromNotePattern, chordShapeCoveredByStandaloneNotes,
hsStart, hsEnd, handShapeChartSpanSec, fillArpeggioGhostInferFlags,
arpeggioChordIdForNoteWithInferCache, arpHsBoundsForNote, fillLaneRailHandShapeFlags,
fillArpeggioRailShapeBoundsCaches, arpeggioLaneOuterRailLaneSlice,
arpeggioLaneOuterRailAtChartTime, arpeggioLaneDividerFrameAccentMul,
arpeggioLaneDividerXYScaleMatchFrameRim) into a createArp({}) factory.

DI surface: 19 params — 18 plain const shorthand + 1 live getter (getNStr).
lowerBoundT imported directly from ./geometry.js (not DI'd).
NEXT_ON_STRING_T_EPS was found during ALL_CAPS grep after initial survey and
added as param 19.

Structural fix: _resetStringDependentCaches() remains in screen.js; exports
resetChordShapeCache() so screen.js can reset _chordShapeCache without reaching
into arp.js internals. DI rewire: arpeggioLaneDividerXYScaleMatchFrameRim uses
getNStr() instead of bare nStr (the live let var).

Test coverage (tests/js/highway_3d_arp.test.js — 19 tests):
- Module shape, 21-export return object, lowerBoundT direct import
- DI param surface (NEXT_ON_STRING_T_EPS, getNStr getter)
- Screen.js wiring: import, T-section body gone, destructure callsite
- Wiring-correspondence guard (PINNED_RENAMES = {}, naming-class invariant)
- Amendment 2: resetChordShapeCache identity kill (gut reset → r3 === r1 → RED)
- Amendment 3: WeakMap re-keying guard (same-ref hit, new-ref recompute)
- Behavioral kills: mergeChordShape, mergeHandShapeSynthChords, chordWireHighDensity,
  chordTemplateLabel, arpeggioLaneDividerXYScaleMatchFrameRim DI check

Also updates highway_3d_arp_deferral.test.js to look in src/arp.js for
chordShapeCoveredByStandaloneNotes (moved out of screen.js by this cut).

plugin.json: 3.47.0 → 3.48.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 18:32:25 +02:00
byrongamatosandClaude Sonnet 4.6 930b492fa5 h3d-carve-11: extract R-section (string glow) into src/string-glow.js
VERBATIM-MOVE of updateStringHighlights from screen.js into a new
createStringGlow() factory. 7 DI-rewires at function entry (aliased
locals), plus 1 plain const shorthand. 8 DI params total, 0 setter
pairs, single export { updateStringHighlights }.

screen.js changes:
- Function definition (old 6610–6649) replaced with createStringGlow({…})
  factory destructure
- mStr confirmed absent from updateStringHighlights (plan row 11 stale;
  declared surprise in contract, accepted by god)
- VENUE_GEM_EMISSIVE_MUL passed as plain const shorthand (not a getter)

Tests (highway_3d_string_glow.test.js, 11 new, all green):
- Module shape + DI rewire source-scans
- Wiring-correspondence guard (createStringGlow, empty PINNED_RENAMES)
- 3 behavioral tests: emissive/opacity writes, venue multiplier,
  null-mesh slot safety — behavioral kill: gut loop → assert RED

Suite: 1317/1319 pass. Pre-existing failures #46 (legacy analyser)
and #639 (nut-labels) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 17:51:54 +02:00
byrongamatosandClaude Sonnet 4.6 4b2e4172c5 h3d-carve-10: extract K-section (score FX) into src/score-fx.js
VERBATIM-MOVE of the score-FX block from screen.js into a new
createScoreFx() factory. 7 DI-rewires only (getNdFrameNowMs,
getCam, getProbe, getNStr, getCurX, getHighwayCanvas × closure,
sY shorthand). No logic changes.

Four regions in screen.js replaced with factory callsites:
- State block (old lines 3619–3720): createScoreFx({…}) destructure
- drawScoreFx body (old 12564–12672): tombstone comment
- Init block (old 5839–5862): fxInit() callsite
- Teardown lines (old 12796/12814–12820): fxTeardown() callsite;
  _fxOnSkin removal moved into fxTeardown(), feedBack block
  restructured to contain only non-K-section listeners.

Tests (highway_3d_score_fx.test.js, 37 new, all green):
- Gut-audit: all 4 exports + _fxHandle + _fxSpawnPop paths
- Class-killers: fxTeardown listener removal, _fxGen increment
- Verbatim-declaration: no re-entry guard in fxInit (per dispatch)
- Wiring-correspondence guard: createScoreFx({…}) naming (cut-9
  pattern, empty PINNED_RENAMES)

Pre-existing failures: #46 (legacy analyser), #628 (nut-labels) —
both pre-date this cut, confirmed against 4a45ed8 baseline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 17:19:01 +02:00
byrongamatosandClaude Sonnet 4.6 f401127c20 test(h3d-carve-9): naming-correspondence guard for createCamera wiring (Creed r1)
Adds a structural source-scan test that verifies every entry in the
createCamera({...}) argument object satisfies its naming class:

  - Shorthand entries (plain consts, sY): key===value by definition, skipped.
  - Getter arrows (getXxx: () => [_]var): var stem (stripped of optional _)
    must equal key minus 'get' prefix (getCurX → curX, getProbe → probe).
  - Setter arrows (setXxx: (v) => { [_]var = v; }): same rule for set.
  - Fn-ref renames (freeCamFor: _freeCamFor, etc.): must appear in the
    exhaustive PINNED_RENAMES map; any unregistered key:value entry fails.

Kills the entire param-swap class (not just the CAM_H_BASE/CAM_DIST_BASE
instance Creed probed). Cut-13 additions inherit the guard automatically;
new fn-ref renames only need a one-line PINNED_RENAMES entry.

Mutation proofs (all RED before revert):
  (a) CAM_H_BASE: CAM_DIST_BASE, CAM_DIST_BASE: CAM_H_BASE → not ok 14
  (b) getCurX: () => curDist (wrong var stem) → not ok 14
  (c) bogusKey: _someOtherFn (unregistered rename) → not ok 14

Gut-audit: severing the violation check (violations.push suppressed) → the
assert.deepEqual(violations, []) always passes — so the push lines are the
live part; removing any one of the three class branches lets its mutation
class through silently.

Suite: 1272/1274 pass (same 2 pre-existing failures, +1 new test).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 16:55:49 +02:00
byrongamatosandClaude Sonnet 4.6 10c7ec8a9d refactor(h3d-carve-9): extract W-section (camera lerp) → src/camera.js
effectiveVfov + camUpdate (~192 lines) extracted from the screen.js IIFE
into a createCamera() ES-module factory in plugins/highway_3d/src/camera.js.
screen.js imports and destructures the return { effectiveVfov, camUpdate }.

DI surface (48 params): 22 plain constants, 11 getters, 5 getter+setter pairs,
5 fn-refs (DI-renamed: _freeCamFor→freeCamFor, _aspectPaneKey→aspectPaneKey,
_resolveTuneFor→resolveTuneFor, _aspectRegisterPane→aspectRegisterPane).
Peer ES imports at module level: computeBPM (geometry.js), _ssActive (utils.js).
Per-call write-backs: setCurX, setCurDist, setCurLookY, setTgtLookY,
setFretRowFitBoost (all write-backs confirmed by setter class-killer tests).

Test retargeting (54 tests across 4 files):
- highway_3d_wide_fov.test.js:   6 tests → cameraSrc; 4 regexes updated for
  DI-renamed fn refs (resolveTuneFor, aspectRegisterPane, aspectPaneKey/getPaneUid)
- highway_3d_camera_framing.test.js: 5 tests → cameraSrc; getTgtDist() regex fix
- highway_3d_camera_bootstrap.test.js: extractFn retargeted to cameraSrc;
  getTgtX() ordering-check fix; 2 new setter class-killers added (setCurX,
  setFretRowFitBoost)
- highway_3d_lefty.test.js: shoulder-offset test → cameraSrc + getLeftyCached()
- highway_3d_panel_controls.test.js: createCamera stub added

Bite proofs:
- Gut effectiveVfov in camera.js → wide_fov not ok 4 (RED) ✓
- Gut camUpdate body (H_NEAR lerp) → framing not ok 3 (RED) ✓
- Gut camUpdate body (curX+=) → bootstrap not ok 11 (RED) ✓
- Sever setCurX → bootstrap not ok 12 (RED) ✓
- Sever setFretRowFitBoost → bootstrap not ok 13 (RED) ✓

Suite: 1271/1273 pass; 2 pre-existing failures unchanged from cut-8 baseline
(#46 analyser fallback, #591 nut-labels — both in flight before cut 9).

plugin.json: 3.44.0 → 3.45.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 16:37:20 +02:00
byrongamatosandClaude Sonnet 4.6 4a45ed8782 test(fx): source-scan BG_DEFAULTS.nutColor fixture (Creed r2)
test 11 now extracts the real nutColor from screen.js via regex and
pins it against a literal ('#f5f3f0').  Three-layer guard:
  1. guard: BG_DEFAULTS literal is present in screen.js
  2. guard: nutColor key is extractable
  3. literal-pin: extracted value == '#f5f3f0' → fail loudly if
     production drifts (verified: mutating screen.js to '#deadf5'
     makes the test fail on the pin assertion; revert confirmed clean)
Fixture and fallback assertion both use PROD_NUT_COLOR (the extracted
value), so they track production automatically.

panel_controls stub updated: added _applyBloom to the createFx stub
(Toby r1 added _applyBloom to the return set; stub was one short).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 16:07:37 +02:00
byrongamatosandClaude Sonnet 4.6 1f3bde73d1 test(h3d-carve-8): add discriminating tests for _applyCinematic and _h3dHexOrDefault
Creed gap fix: two functions had no test that went RED when gutted.

_applyCinematic: tests both paths (cinematic=true and false) against
  fake ambLight/dirLight objects with intensity fields. Asserts all 4
  expected intensity values. Mutation `return` at entry → RED (4 fail).

_h3dHexOrDefault: tests valid hex parse ('#a1b2c3' → 0xa1b2c3), no-#
  fallback (regex requires #), gibberish fallback, and explicit defHex
  override. Mutation (return default always) → RED (valid-hex assertion fails).

Suite: 1269/1271 (same 2 pre-existing failures unchanged).

Note: from cut 9 onward, self-audit step added before reporting — every
moved function names which test kills it; no uncovered function ships.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:58:33 +02:00
byrongamatosandClaude Sonnet 4.6 d060c49238 test(h3d-carve-8): fix Toby r1 findings — _applyBloom extract + test 7 vacuity
F1 (MED — declared gap confirmed and widened):
  Extract _bloomEnsure's .then() body into named _applyBloom([EC, RP, UB, OP])
  at factory scope. _applyBloom added to return set and screen.js destructure.
  Test 9 calls _applyBloom directly with mock module objects (no import() needed).
  All 4 write-back mutations now RED:
    sever setBloomPass(bp)    → RED
    sever setBloomW(w)        → RED
    sever setBloomH(h)        → RED
    sever setComposer(comp)   → RED

F2 (MED — vacuous OR clause in test 7):
  Old:
       The  arm is always true (no living sparks) → NO-OP passes.
  Fix: replaced with independent AND assertions on BOTH needsUpdate flags.
  NO-OP mutation (return; at top of _sparkUpdate) now goes RED.

DI beyond-subst count +5: _applyBloom reads T/ren/scene/cam/highwayCanvas
from live-accessors (instead of inheriting _bloomEnsure's locals).
fx.js return set: 6 → 7 symbols (added _applyBloom).
Suite: 1267/1269 (2 pre-existing failures unchanged).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:51:42 +02:00
byrongamatosandClaude Sonnet 4.6 9b98d78cbe refactor(h3d-carve-8): extract Q-helpers (lighting/FX) → src/fx.js
Six functions extracted from Q-section of screen.js into a new
src/fx.js ES module behind a createFx({...}) factory:
  _h3dHexOrDefault, _applyCinematic, _timingHex,
  _sparkBurst, _sparkUpdate, _bloomEnsure

buildBoard (330 lines) deferred to plan §3 row 16 (P-section cut):
its write-back surface spans 9 factory-scope vars owned by P/U/Y —
premature extraction would require ~50 DI params. See plans/highway3d-carve.md.

DI surface (26 beyond-subst rewires):
  BG_DEFAULTS, K          — plain IIFE-scope constants
  getT                    — live-accessor (Three.js, lazy)
  getAmbLight/getDirLight/getCinematic/getTimingFx — live-accessors
  getSparkPts/.../getSparkN — live-accessors (per-call, not init-cached)
  setSparkPts/setSparkPos/setSparkVel/setSparkCol/setSparkLife — setters
  getComposer/setComposer — getter+setter (_bloomEnsure lazy reassigns)
  getBloomLoad/setBloomLoad/getBloomPass/setBloomPass — getter+setter pairs
  getBloomW/setBloomW/getBloomH/setBloomH — getter+setter pairs
  getRen/getScene/getCam/getHighwayCanvas — live-accessors (null pre-init)
  canvasSize              — factory function ref (stable)

Class-killers (all mutations verified RED before commit):
  - _timingHex EARLY branch removed → RED
  - _sparkBurst stale init-time sparkPos cache → RED (live-accessor required)
  - setBloomLoad silenced → RED (synchronous write-back path)
  - setComposer getComposer init-cached → RED (live getComposer() required)
  GAP declared: setComposer(comp) inside .then() (async, no import() mock)

Tests: 1266/1268 (2 pre-existing failures unrelated to this cut)
plugin.json: 3.43.0 → 3.44.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:37:46 +02:00
byrongamatosandClaude Sonnet 4.6 4b87db0c17 test(h3d-carve-7): add discriminating render-path tests for drawToneHud and drawSectionHud
Creed gap: both functions only tested with empty/null state — a return 0 at
the body entry point kept the suite green (Creed's exact injection at
overlay.js:631 for drawToneHud; equivalent non-empty branch gut for drawSectionHud).

Fix: two new recording-ctx tests (tests 9–10) that drive real rendering:
  • drawToneHud: toneBase='Clean', next change 'Lead' at t=10, currentTime=5
    → assert boxH>0, fillText contains 'Clean' and 'Lead', ctx.fill() called
  • drawSectionHud: sections=[Intro@0, Verse@10], currentTime=5
    → assert boxH>0, fillText contains 'Intro' and 'Verse', ctx.fill() called

Named mutations verified RED:
  Mut 1: return 0 at drawToneHud body start (overlay.js:631) → test 10 RED
  Mut 2: return 0 after drawSectionHud empty-guard → test 11 RED

Shipped code: zero changes. Test files only.
Suite: 262/262 on 0a91f4f (base) → 264/264 (+2 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 15:14:53 +02:00
byrongamatosandClaude Sonnet 4.6 0a91f4f1b1 refactor(h3d-carve-7): extract O-section (lyrics + HUD overlay) → src/overlay.js
Move longestConsecutiveRun, drawChordDiagram, _drawDiagramCached,
drawSectionHud, drawToneHud, drawLyrics out of screen.js IIFE into
plugins/highway_3d/src/overlay.js as export function createOverlay({ diagRenderCache }).

Surprises vs approved contract:
  • longestConsecutiveRun (lines 4338–4352) co-moved — called only by drawChordDiagram
  • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX DELETED from screen.js (lines 573–575);
    only users were inside the O-section; moved not copied to avoid drift
  • _DIAG_CACHE_MAX DELETED from screen.js factory scope (line 3256); moved not copied

Beyond-subst rewires (2):
  1. Factory wrapper createOverlay({ diagRenderCache })
  2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached

Base run stated: 253/253 on a993d2b
Post-cut: 262/262 (+9 tests — 8 overlay class-killers + 1 panel-controls stub)

Named mutations verified RED:
  • createOverlay({ diagRenderCache: new Map() }) → test 6 RED (ref-severance)
  • void _DIAG_CACHE_MAX in screen.js         → test 4 RED (bare private)

plugin.json: 3.42.0 → 3.43.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 14:53:03 +02:00
byrongamatosandClaude Sonnet 4.6 a993d2b291 test(h3d-carve-6): strengthen stale-private guard to catch full class (Toby r1)
Replace hardcoded ['_pmXSpriteMat','_fhXSpriteMat'] list in test 4 with
a full factory-depth-1 scan of materials.js (4-space indent const/let
declarations NOT in the return set). This catches TXT_STYLES and any
future factory-private additions automatically.

Mutation-verified: void TXT_STYLES injected into screen.js → test 4 RED;
reverted → 16/16 GREEN. Full h3d suite: 253/253, 0 fail.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 12:40:34 +02:00
byrongamatosandClaude Sonnet 4.6 e6b2f86068 refactor(h3d-carve-6): move N-section material builders to src/materials.js
Extract TXT_STYLES + 13 material builder functions (txtMat, pinchHarmonicMat,
naturalHarmonicMat, palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat, _meshMatForGhostFretDigit,
_spriteMat2MeshMat) and pool() from screen.js N-section into:
  plugins/highway_3d/src/materials.js  (createMaterialBuilders factory)

screen.js drops ~600 lines (15302→14705).

Surprises vs plan §4:
  • DI is 4 params { getT, getTxtCache, techMatCache, techMeshMatClones } not 1
  • _syncOpenStringPitchLabels cluster excluded (20+ factory-scope deps)
  • _techMatCache stays in screen.js factory scope for teardown .values()/.clear()

Beyond-subst (4):
  1. Factory wrapper createMaterialBuilders({...})
  2. T → const T = getT() inside each function body (live accessor)
  3. txtCache[k] → const cache = getTxtCache(); cache[k]
  4. _techMatCache/_techMeshMatClones → DI param names techMatCache/techMeshMatClones

Tests: 16 new class-killer tests in highway_3d_materials.test.js;
pool warm tests retargeted to src/materials.js; panel_controls stub added.
Full suite: 1246/1248 pass (2 pre-existing: network + nut-labels).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 10:24:40 +02:00
byrongamatosandClaude Sonnet 4.6 733d772154 test(h3d-carve-5): pin literal tables — _PC_C, _PC_PILL, _PC_LABELS, _PC_USES (test 15)
Creed finding: DOM/style literals in the moved factory were never asserted;
a single-value change (e.g. idle '#181830'→'#181831') shipped silently.

Add test 15: table-driven source scan pinning all 6 _PC_C color entries,
4 _PC_PILL CSS fragments, all 8 _PC_LABELS display strings, and all 9
_PC_USES intensity/reactive boolean pairs.

Mutation-verified RED:
  idle '#181830'→'#181831'       → not ok 15, fail 1 (Creed's mutant)
  particles intensity true→false → not ok 15, fail 1

Suite: 236/236 → 237/237.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:55:36 +02:00
byrongamatosandClaude Sonnet 4.6 90283f9d3a test(h3d-carve-5): fix test 14 defined-set to full identifier scan
Toby r2 finding: the declaration-only regex only captured the first
variable from multi-var let statements. bg-control.js lines 81-88 have
a single let with 8 state symbols (_pcEl, _pcSel, _pcReactive, _pcIntensity,
_pcIntensityWrap, _pcReason, _pcRetry, _pcRetryTimer); 7 of 8 were
invisible to the guard, so a bare _pcEl ref in screen.js escaped all tests.

Fix: replace the declaration-pattern matchAll with a full identifier
scan (/\b(_pc\w+)\b/g). Every _pc* word in bg-control.js lands in
`defined`; the privateSymbols filter (excluding destructured public API)
is unchanged.

Mutation-verified RED:
  _pcEl.remove() injected in screen.js → not ok 14, fail 1 (Toby's mutant)
  _pcSync() injected in screen.js      → not ok 14, fail 1 (Mut B, still works)
Suite: 236/236.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:49:23 +02:00
byrongamatosandClaude Sonnet 4.6 97799fff37 test(h3d-carve-5): fix test 13 kill + add stale-private guard (test 14)
Toby r1 finding: test 13's body-ref filter let the stated mutation escape
('add _pcNewFn to return only → RED' was actually GREEN), and the cut-4
stale-private-reference class (_pcSync bare ref in screen.js) was also
unguarded.

Fix test 13: drop the && screen.js body-ref condition from the leaked
filter — pure 'returned ⊆ destructure' check. Mutation-verified RED:
add _pcNewFn to return but not destructure → leaked = ['_pcNewFn'] → fail 1.

Add test 14: extract every _pc* symbol defined in bg-control.js (function/
let/const declarations), filter to private (not in the destructure), assert
none appears bare in screen.js IIFE body after stripping imports, block
comments, line comments, and the destructure statement. Mutation-verified RED:
inject _pcSync() in screen.js body → stale = ['_pcSync'] → fail 1.

Suite: 235/235 (c4eebe1 base) → 236/236 tip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:43:30 +02:00
byrongamatosandClaude Sonnet 4.6 c4eebe1c9f refactor(h3d-carve-5): move H-section player-chrome bg-control to src/bg-control.js
Extracted _pc* subsystem (420 lines) from screen.js IIFE into
src/bg-control.js using a factory DI pattern (createBgControl({...})).

Exports: createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe,
         _bgUnsubscribe, getVenueSceneOverride })
         → { _pcAcquire, _pcRelease }
screen.js: const { _pcAcquire, _pcRelease } = createBgControl({...})

Beyond-subst (2):
1. Factory wrapper (IIFE-scope closure → DI params) — factory export pattern
   required because DI values are IIFE-scope, not ES module imports.
2. _venueSceneOverride → getVenueSceneOverride() (live accessor, 1 call site
   in _pcSync — mutable let at screen.js:1523 must be read per-call).

DI values all defined before the createBgControl call (screen.js):
- BG_STYLE_IDS: line 1435 | _bgReadGlobal: 1825
- _bgSubscribe/_bgUnsubscribe: 1917-18 | _venueSceneOverride: 1523
First _pcAcquire caller: init() in createFactory() (~line 14850 post-cut).
Construction order correct: createBgControl call before createFactory.

Surprise declared to god before commit (outbox/h3d-cut5-surprise.json):
No bc-panel.js dependency — §8's anticipation was wrong. The
_bcCreateController call at what was ~line 8082 is in _bcSyncMode
(P-section / factory scope), not the H-section. bg-control.js has ZERO
dependency on bc-panel.js.

Tests:
- tests/js/highway_3d_bg_control.test.js (new, 13 class-killers):
  stranded-caller (test 13, factory-adapted from bc-panel test 12),
  construction-order (tests 11-12), DI completeness (test 2),
  live-accessor enforcement (test 3), lifecycle (tests 4-7).
- plugins/highway_3d/tests/background_control.test.js: retargeted from
  screen.js slice → bg-control.js factory eval; all 20 existing behaviour
  tests preserved (load() uses vm.createContext + augmented return getters).
- tests/js/highway_3d_panel_controls.test.js: createBgControl stub added.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
  base 222/222 (f69c544) → tip 235/235 (+13: 13 new class-killers)
plugin.json: 3.40.0 → 3.41.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:25:44 +02:00
byrongamatosandClaude Sonnet 4.6 f69c544eea fix(h3d-carve-4): export _bcLoadSettings + _bcFfIdx; add caller-coverage test (Creed HIGH)
screen.js H/P-section render path at lines 15396-15402 calls _bcLoadSettings()
and _bcFfIdx() (3×) — both moved to bc-panel.js in cut 4 but omitted from the
export list.  Browser: first render() after bcCtrl creation → ReferenceError;
seek/loop fast-forward index also dead.  Suite was green because no test
executed the butterchurn render path.

Fix:
- export _bcLoadSettings and _bcFfIdx from bc-panel.js
- add both to the tagged import in screen.js

Class-killer (test 12 — generic, not instance-specific):
  Extracts all exports from bc-panel.js, all imports in screen.js's
  bc-panel.js import clause, then asserts no exported symbol appears as a
  bare reference in the screen.js IIFE body without being imported.
  Generic: adding a new export + new caller without updating the import → RED.

Own grep (audit):
  grep (non-comment lines, all private _bc* names from bc-panel.js):
    _bcLoadSettings  1 hit  (line 15396)
    _bcFfIdx         3 hits (lines 15400-15402)
    all others: 0 hits
  Only the two symbols Creed found.

Mutation-verify:
  remove _bcLoadSettings from screen.js import →
    node --test tests/js/highway_3d_bc_panel.test.js
    tests 12   pass 11   fail 1   (test 12 RED)  ✓
  restore →
    node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
    tests 222   pass 222   fail 0               ✓ GREEN

Plan §8 amended: exports 2→4 with dated note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 09:03:10 +02:00
byrongamatosandClaude Sonnet 4.6 81b366746e test(h3d-carve-4): fix vacuous module-scope check in bc_panel.test.js (Toby r1)
Test 2 used s.indexOf which matched the JSDoc comment at offset 133, not
the real assignment at line 183 — so it passed even with the assignment
indented inside a function.

Fix: s.search(/^window\.h3dBcApplySettings\s*=/m) anchored to line-start.
An indented assignment (inside a function) does not match /^window\./m and
returns -1.  Also removed the erroneous firstFnIdx comparison: the assignment
is legitimately after _bcIsDesktop in the file and still at module scope.

Mutation-verify:
  indent assignment → node --test tests/js/highway_3d_bc_panel.test.js
    not ok 2  (fail 1/11)  ✓ RED
  restore     → node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
    tests 221   pass 221   fail 0  ✓ GREEN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:55:06 +02:00
byrongamatosandClaude Sonnet 4.6 4e060cbf7b refactor(h3d-carve-4): extract Butterchurn panel to src/bc-panel.js
Move all _bc* constants, mutable state, and functions (~670 lines) from
screen.js B-section (lines 27-695) to src/bc-panel.js.

Public API: _bcCreateController, _bcIsDesktop (2 named exports).
window.h3dBcApplySettings assigned at bc-panel.js module scope (1
beyond-subst; body verbatim — scope moves from IIFE to ES module top-level).
THREE_URL / THREE_CDN dead-code tombstoned (unused since three-loader.js).
Vendor files untouched (R2); asset URL constants verbatim (R3).

Delta note: original survey estimated ~1,333 lines for B-section. Actual is
~670 because (a) prior cuts 1b/2/3 moved material that was interleaved in the
B-section range, and (b) the original survey section boundaries were wrong —
the B-section ends where the H-section factory begins, not at line 695 of the
pre-cut file.

Completeness grep (post-cut):
  grep -nP '^\s*(function|const|let|var)\s+_bc' screen.js
  → 2 hits: _bcActive and _bcSyncMode at line 8081-8082 (factory-scope
    H/P-section helpers that orchestrate the imported controller; not B-section
    symbols — confirmed F-section in the survey table).
  B-section _bc* definitions in IIFE: 0.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
  tests 221   pass 221   fail 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:43:00 +02:00
byrongamatosandClaude Sonnet 4.6 6050b6262b fix(h3d): thread maxStrings param through resolveStringCount (Toby r1)
Toby r1 on c7f7c88: MAX_RENDER_STRINGS=6 hardcoded in utils.js is a
stale compile-time copy — if S_COL grows to 7 entries, resolveStringCount
silently clamps a 7-string chart to 6 and no test fails.

Fix: delegator-param pattern (mirrors fretX / geoFretX):
  - Remove const MAX_RENDER_STRINGS from utils.js
  - resolveStringCount(bundle, maxStrings) — param replaces the copy
  - _openStringPitchLabelsForTuning(bundle, songInfo, n, maxStrings) — same
  - screen.js import aliases (_resolveStringCountBase, _openStringPitchLabelsForTuningBase)
  - 1-line delegators in IIFE supply MAX_RENDER_STRINGS (= S_COL.length); zero call sites change
  - NSTR=6 kept (it is a fixed semantic fact about standard guitar, not a palette ceiling)

New test: 'maxStrings param is authoritative, not a hardcoded 6'
  — resolveStringCount({stringCount:7}, 7)=7; re-hardcode mutation → RED.
Wiring tests: delegator lines asserting _resolveStringCountBase/
_openStringPitchLabelsForTuningBase each receive MAX_RENDER_STRINGS.

Mutation-verified: re-hardcode 6 → 1 RED; original → 210/210 GREEN.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
207→210/210 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:02:00 +02:00
byrongamatosandClaude Sonnet 4.6 c7f7c88c62 refactor(h3d): extract color/tuning/splitscreen utils to src/utils.js (h3d-carve-3)
Moves 12 pure-function / compile-time-constant exports from the screen.js
IIFE into a new src/utils.js ES module:

  Color utils: _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt
  String-count: resolveStringCount
  Tuning/pitch: _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8,
                _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning
  Splitscreen:  _ssActive, _ssIsCanvasFocused

Free-identifier audit: all clean. resolveStringCount uses MAX_RENDER_STRINGS
and NSTR — copied as compile-time constants (both = 6) matching IIFE values.
_ssActive/_ssIsCanvasFocused read window.feedBackSplitscreen live per call.

Survey discrepancy declared: the 12 functions span two non-contiguous regions
in current screen.js (lines 741–882 and 1490–1503) rather than the plan's
original 1703–2193 range; function list from the plan is exact.

New test file highway_3d_utils.test.js: 19 class-killer tests.
Mutation-verified (3-char shorthand removal → RED, original → GREEN).
Panel-controls sandbox: 16 stubs added for the new imports.

Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
188→207/207 pass.

Plugin: 3.38.0 → 3.39.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:52:41 +02:00
byrongamatosandClaude Sonnet 4.6 f75e91088f test(h3d): catch const-shadow mutation in T=mod assertion (Toby r1)
Toby r1 finding on 8ea123d: the count-based check /T\s*=\s*mod\s*;/g
matches even when the .then bodies declare 'const T = mod' — a local
shadow that leaves the module-level live-binding T permanently null.
Fix: add doesNotMatch(/(?:const|let|var)\s+T\s*=\s*mod/) to reject
any declaration form.

Mutation-verified: 7/8 RED under const-shadow, 188/188 GREEN on original.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:42:18 +02:00
byrongamatosandClaude Sonnet 4.6 8ea123deaf refactor(h3d-carve-2): extract Three.js loader to src/three-loader.js
Move the D-section (~21 lines) from screen.js factory scope to its own module.

Exports:
- loadThree() — memoised import() with local-vendor→CDN fallback; same body verbatim
- T (live let-binding) — updated to the Three.js namespace on first resolution

screen.js gains: import { loadThree, T } from './src/three-loader.js'
screen.js loses: let T = null; let threeLoadPromise = null; function loadThree()
T and loadThree remain accessible to the IIFE via module-scope closure. The live
let-binding means the IIFE reads the populated T after loadThree() resolves
without any call-site changes.

Panel-controls vm test: strip regex extended to consume all consecutive import
lines; T: null + loadThree stubs added to sandbox context.

Class-killer tests (8 new — highway_3d_three_loader.test.js):
- loadThree exported (mutation: rename/remove → import fails)
- T exported as mutable let (mutation: const → T=mod throws TypeError)
- T=mod in both .then handlers (mutation: remove both → T stays null)
- memoisation guard !threeLoadPromise (mutation: remove → race + duplicate loads)
- CDN fallback .catch chain (mutation: remove → deploy failures unrecoverable)
- threeLoadPromise reset on failure (mutation: remove → no retry possible)
- screen.js imports loadThree+T (wiring confirmed)
- IIFE no longer declares local T or threeLoadPromise (shadow defeated)

All class-killer mutations confirmed distinguishable before commit.

Base run (5e401af): 180/180 pass.
Post-cut run: 188/188 pass.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:35:27 +02:00
byrongamatosandClaude Sonnet 4.6 5e401afe87 refactor(h3d-carve-1b): extract render-order, note-key, camera-bootstrap, fretMid to src/geometry.js
Move 9 symbols verbatim from screen.js factory scope to geometry.js:
- RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO,
  RENDER_ORDER_FAR_CLAMP — compile-time constants, now exported from geometry.js
- renderOrderForLayerAtZ — pure fn; reads K + RENDER_ORDER_* from geometry scope
- _noteKey, lowerBoundT — hot-path helpers; no external deps
- hwyFirstRelevantFrettedTime — camera bootstrap scan; no external deps
- fretMid → geoFretMid(f, uniform) — same fretX delegator pattern as Cut 1;
  screen.js keeps: const fretMid = f => geoFretMid(f, _h3dFretUniform); (1 beyond-subst)

screen.js import line updated to import all 9 new exports; tombstones replace
each original definition.

Source-scan retargets:
- highway_3d_render_order.test.js: layers()/zZeroRenderOrder() now read
  GEOMETRY_JS; the 5 renderOrderForLayerAtZ-internals asserts in
  chordFrameRenderOrder test retargeted to geo() (call-site assert stays on src()).
- highway_3d_camera_bootstrap.test.js: extractFn now reads geoSrc (GEOMETRY_JS);
  sourceBetween wiring tests remain on SCREEN_JS.
- highway_3d_panel_controls.test.js: sandbox stubs extended with 9 new names.

Class-killer tests added to highway_3d_geometry.test.js (8 new tests, 180 total):
- RENDER_ORDER_LAYER_STACK length + first/last entries
- RENDER_ORDER_LAYER_INDEX spot-checks (CHORD_FILL=0, NOTE_CORE=10)
- renderOrderForLayerAtZ far-clamp (worldZ=-5 gives 50, not 33 without max)
- renderOrderForLayerAtZ unknown-layer throws
- _noteKey |0 truncation (1.5,3)=150003 not float-derived 150008
- lowerBoundT strict lower-bound (3 in [{t:1},{t:3},{t:5}] gives 1, not 2)
- hwyFirstRelevantFrettedTime smoke (empty → null)
- geoFretMid sentinel (f=0 gives -2K≈-0.015, not 0) + ratio invariant

Mutation analysis confirmed all 8 tests fail under their named mutation before
committing (per Toby r1 lesson).

Base run (a1f7ad5): 172/172 pass.
Post-cut run: 180/180 pass.
Command: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:19:58 +02:00
byrongamatosandClaude Sonnet 4.6 a1f7ad5e68 test(h3d-carve-1): add class-killer coverage for camBaseDistU, camLowFretPullbackU, _makeGaussTex
Toby r1 finding: three exports in src/geometry.js had no class-killer tests.
Named mutations that were undetected before this commit:
  - camBaseDistU: Math.max(span,4)→span leaves camBaseDistU(0)=65 vs 77
  - camLowFretPullbackU: drop Max(0,..) leaves (10)=-20 vs 0
  - _makeGaussTex: default sigma=0 leaves centre-pixel alpha=0 vs 255

Each test is written to call without the value that makes the mutation
transparent (sigma passed without default; span=0 for the floor case;
fret=10 for the clamp case). Mutation verification run before commit:
all three mutations confirmed caught, all three pass on real code.

Suite (cmd): node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Base (84d3376): 169/169
Tip:          172/172  (+3 discriminating tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 07:05:24 +02:00
byrongamatosandClaude Sonnet 4.6 84d33769b8 refactor(h3d-carve-1): extract pure geometry helpers to src/geometry.js
Moves 7 exports from screen.js to a new ES module src/geometry.js:
  geoFretX (was fretX, now 2-arg), dZ, slideTrailEnd, camBaseDistU,
  camLowFretPullbackU, computeBPM, _makeGaussTex.

Internal helpers _fretXLog / _fretXUniStep / _fretXUni are module-private
inside geometry.js. Module-level constants (SCALE, K, FRET_SCALE, NFRETS,
FRET_SPACING_*, TS) are duplicated at geometry.js module scope — values
are compile-time and never vary at runtime.

screen.js keeps a 1-arg delegator (1 beyond-subst):
  const fretX = f => geoFretX(f, _h3dFretUniform);
No call site changes; fretMid / fretColumnWorldW / slideOffsetWorldX /
_recomputeFretSpacingDerived stay in screen.js and use the delegator.

Tests:
- highway_3d_geometry.test.js: class-killer via import() with 9
  known-answer assertions (geoFretX uniform/log invariants, dZ linearity,
  slideTrailEnd, computeBPM BPM estimate).
- highway_3d_fret_spacing: test updated to match delegator pattern + adds
  GEOMETRY_JS read to verify geoFretX export.
- highway_3d_sustain_bloom: retargeted _makeGaussTex check to GEOMETRY_JS
  (definition moved); call-site check stays on SCREEN_JS.
- highway_3d_panel_controls: vm loader strips ES import line and injects
  geometry stubs; all factory-statics tests unaffected.

Suite: 169/169 pass
  node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
Version bump: 3.35.0 -> 3.36.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 06:50:52 +02:00
byrongamatosandClaude Sonnet 4.6 d5f622f31b refactor(h3d-carve-0): add scriptType:module to plugin.json
Zero logic changes. screen.js content byte-identical — an IIFE is valid
inside a <script type="module">. The loader consumes scriptType at
plugin-loader.js:666 (if plugin.script_type === "module" -> script.type =
"module"), fed from plugins/__init__.py:1429 (manifest.get("scriptType")).

Isolates the R7 module-flip risk as its own verifiable cut before any
function extraction begins.

Version bump: 3.34.1 → 3.35.0
Suite: 160/160 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 06:35:26 +02:00
byrongamatosandClaude Fable 5 7bce521a5b Merge fix/scan-prune-guard: refuse mass prune on automatic scans
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
2026-09-04 14:35:47 +02:00
byrongamatosandClaude Sonnet 4.6 cafb1ee790 fix(scan): mass-prune guard v2 — partial degraded listing refused on auto scan
The v1 zero-listing guard (f6e9727) was bypassed by a partial degraded
mount: if even one song was visible, current_files was non-empty and
delete_missing ran freely, pruning every invisible DB row (Creed r1 HIGH).

Guard contract (god ruling):
- Auto scans (startup, periodic, /api/rescan): refuse when
  would_remove >= max(_PRUNE_MAX_ABS=1, _PRUNE_MAX_FRAC=0.5 * existing).
  Zero-listing also refused (would_remove == existing).
  Scan sets stage='error', leaves DB intact.
- /api/rescan/full: allow_mass_prune=True → guard logs a warning and
  proceeds; delete_missing runs (user-authorised explicit intent).

Changes:
- lib/scan.py: _PRUNE_MAX_ABS/FRAC constants; combined guard (zero +
  partial) before delete_missing; allow_mass_prune param on
  background_scan(); _scan_mass_prune_next global threaded through
  kick_scan() and _scan_runner()
- server.py: trigger_full_rescan calls kick_scan(allow_mass_prune=True)
- tests/test_scan_prune_guard.py: Case B (Creed partial, RED f6e9727→
  GREEN here) + Case C (full-rescan bypass)

Gates: pytest 2816/2816, JS 1155/1155

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-04 13:45:04 +02:00
byrongamatosandClaude Sonnet 4.6 f6e9727b04 fix(scan): refuse to prune when listing returns 0 songs and DB is non-empty
When background_scan() discovers zero songs in the DLC directory but the
songs table is non-empty, skip delete_missing and return with stage='error'.

An empty listing on a non-empty library almost certainly means the DLC mount
was temporarily inaccessible (FUSE remount, NTFS dirty-flag RO fallback, brief
unmount mid-scan) rather than every song being genuinely deleted.
delete_missing({}) on a non-empty DB deleted ALL rows — the #P1-libpurge
incident that wiped 50,943 songs was caused by exactly this path.

The guard fires only when current_files is empty AND the DB has at least one
row, so a genuinely empty new library is unaffected.

Test: test_scan_prune_guard.py::test_empty_listing_refuses_prune_when_db_nonempty
Failing input: dlc dir with no feedpak/sloppak/wem + 1 DB row.
Before: delete_missing({}) fires, row gone.
After:  stage='error', row survives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-04 13:18:46 +02:00
byrongamatosandClaude Fable 5 b739e44e3d Merge feat/career-gig-tuning: gig tuning preference + tuner interstitials
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
2026-09-03 18:49:02 +02:00
byrongamatosandClaude Sonnet 4.6 2ae46fe754 test(career): F2b — drive real closeBook() in in-flight-discard test (Creed closer)
The previous test for closeBook() invalidating in-flight requests bumped
_ppBookGen directly via setBookGen instead of calling the production closeBook().
Deleting ++_ppBookGen from closeBook() left the test green — decoration.

Fix: expose closeBook via the __careerPassportTest seam and call it directly
in the test. Mutation proof (run before commit):
  - delete ++_ppBookGen from closeBook() → 1 fail (test RED)  ✓
  - restore → 14/14 pass (GREEN)  ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:34:02 +02:00
byrongamatosandClaude Sonnet 4.6 1033de87ce fix(career): gen check after error-branch json + closeBook invalidates in-flight (Creed F1b/F2b)
F1b (MEDIUM): the error path (non-ok response) lacked a gen check after
res.json() completed. A stale 404's json() could finish after a newer booking
had incremented _ppBookGen, and the error handler would still revert the newer
pref to 'any'. Fix: `if (gen !== _ppBookGen) return;` immediately after the
error-branch `await res.json().catch(...)`. Test: json() side-effect bumps
_ppBookGen (simulating a new booking racing in), verifies pref stays 'standard'.

F2b (LOW): closeBook() dismissed the poster but left _ppBookGen unchanged,
so a still-pending successful bookGig response could land after dismissal and
reopen the overlay / repopulate _ppGigProposal. Fix: `++_ppBookGen` in
closeBook(). Test: pending request fires, gen bumped (simulating closeBook),
response resolves, asserts _ppGigProposal stays null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:27:12 +02:00
byrongamatosandClaude Sonnet 4.6 c264a66e5d fix(career): bookGig generation guard + 404-only pref revert (Creed F1/F2)
F1 (MEDIUM): bookGig had no request-generation guard. Rapid pref changes could
let a stale response overwrite _ppGigProposal → user sees the wrong song set.
Fix: _ppBookGen counter incremented on each request; response discarded unless
gen === _ppBookGen at both the res.ok check and after json(). Stale-response
driver test fails without the guard.

F2 (LOW): every non-ok response reverted _ppGigTuningPref to 'any' and
persisted it. A transient 500 would silently blow away the user's pref.
Fix: pref reverted only on 404 (no-match case). Other errors notify but keep
the pref. 500 driver test asserts pref stays 'drop' — fails without the fix.
404 driver still asserts revert to 'any'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:13:53 +02:00
byrongamatosandClaude Sonnet 4.6 c46b6484bf fix(career): specific-tuning interstitial guard + 404-revert re-render (Toby F1/F2/F3)
F1 (HIGH): onGigSongLoading used `pref !== 'specific'` but production pref
is always 'specific:<name>' not bare 'specific' — guard was always true,
interstitial fired even when every gig song shared one tuning.
Fix: !pref.startsWith('specific:')

F2 (MEDIUM): JS test for specific-exemption used bare 'specific' (impossible
in production), giving false confidence. Updated to 'specific:E Standard',
which is the real production shape and correctly exercises the fixed guard.

F3 (MEDIUM): On 404-revert (_ppGigTuningPref → 'any'), poster was not
re-rendered so the stale pill from the previous successful booking stayed
highlighted while internal pref was already 'any'.
Fix: re-render overlay with gigPosterHTML(_ppGigProposal) before returning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 18:02:57 +02:00
byrongamatosandClaude Sonnet 4.6 2a455702b8 feat(career): tuning preference filter + interstitial for gigs
Users can now pick a tuning preference before booking a gig:
- Any (default), Standard only, Drop only, or a specific tuning
- Backend filters the song pool (stubs + filler) by that preference
- Empty-filter case returns a 404 with a descriptive message; frontend
  reverts the pref to 'any' and shows a notification
- Interstitial pause before first song and on tuning changes (all prefs
  except 'specific') via window.feedBack.holdAutoplay(); opens the tuner
  panel in auto mode while the user retunes
- 'Specific' gigs skip interstitials (every song already shares one tuning)
- Graceful degradation: no holdAutoplay → interstitial silently skipped

New backend:
- _tuning_ok_fn helper for standard/drop/specific classification
- _fill_genre_songs accepts optional tuning_ok filter
- propose_gig batch-fetches tuning_name for played stubs, applies filter
- GET /gigs/tunings endpoint for the specific-tuning picker

Tests:
- tests/test_career_gig_tuning.py — 17 Python tests (classification, filter)
- tests/js/career_gig_tuning.test.js — 9 JS tests (interstitial logic)
- tests/plugins/career/conftest.py — songs table schema gets tuning_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 17:52:30 +02:00
byrongamatosandClaude Fable 5 a57b62378c Merge feat/vocal-calibration-wizard: Vocals path + first-run vocal calibration handoff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175AhnWV84XBNuLFSS1CqRT
2026-09-03 15:42:27 +02:00
byrongamatosandClaude Sonnet 4.6 d2847ab153 fix(vocal-cal): cancel fallback timer on Skip via _activeCleanup (Creed finding)
Root cause: clicking Calibrate in the vocalCalibration-absent path queued
an 1800ms setTimeout but never stored the timer id. If the user clicked
Skip before the timer fired, advance('vocals', false) resolved the wizard;
the stale timer then fired advance('vocals', true), mutating the completed
array after resolution and (with a multi-instrument queue) double-
incrementing idx so the next instrument was dropped from both lists.

Fix: capture the setTimeout return value as _timerId and assign:
  _activeCleanup = () => clearTimeout(_timerId)
advance() already drains _activeCleanup on every exit path (Skip,
Calibrate, and any future button), so no further call sites needed.
The _advancing flag + calBtn.disabled remain as the double-click guard;
this fix covers the orthogonal Skip-before-timer race.

Test 3c (new): Calibrate then Skip before timer — asserts clearTimeout
called, vocals in skipped only (not completed), stale timer no-op.
Fails without the fix. Harness updated to expose clearTimeout to vm ctx.

Gates: JS 1141/1141, pytest 60/60.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 14:37:34 +02:00
byrongamatosandClaude Sonnet 4.6 47e85d61c7 fix(vocal-cal): address Toby review F1/F2/F3
F1 (HIGH): add double-click guard to fallback path in renderAudioPanel.
  - Declare `_advancing` flag before click handler; fallback else branch
    returns early if already advancing, then sets flag + calBtn.disabled.
  - Prevents two setTimeout advances from queuing when the button is
    clicked twice before the 1800ms timer fires, which would silently
    drop subsequent instruments from both completed and skipped.
  - Test 4 (new): double-click with two instruments queued, asserts only
    one timer is scheduled -- fails without the guard.

F2 (MEDIUM): add 'vocals' to _inputSetupRelaunch fallback list.
  - Was ['guitar','bass','keys','drums']; now includes 'vocals' so the
    Settings re-calibration wizard runs the vocals panel even when
    /api/progression fails to respond.
  - Test 6 (new): reads fallback literal from source, asserts 'vocals'
    present -- fails without the fix.

F3 (LOW): key button label and notice off vocalCalibration presence for
  vocals, not hasDetector (noteDetect).
  - New `hasVocalCal` and `canCalibrate` vars; vocals shows 'Calibrate'
    iff vocalCalibration facade present, 'Continue' otherwise.
  - notLoadedNotice selects the correct per-instrument message.
  - Test 7 (new): facade present + noteDetect absent => label 'Calibrate'.

Also: fix curly-quote string delimiters introduced by editor autocorrect
in prior commit -- replaced with straight ASCII quotes; restored original
curly apostrophes in prose content (isn’t, it’s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 14:11:22 +02:00
byrongamatosandClaude Sonnet 4.6 95f4bb80e1 feat(vocal-cal): Vocals path + input_setup vocal-calibration handoff
1. Add data/progression/paths/vocals.json (5 levels, instrument:vocals
   challenges) so the Vocals tile appears on wizard step 4.

2. Add vocals to INSTRUMENTS in plugins/input_setup/screen.js (mode:audio)
   so the wizard renders a Vocals panel when that path is selected.

3. In renderAudioPanel, branch vocals away from noteDetect.launchCalibration
   to window.feedBack.vocalCalibration.launch({requester,onDone,onCancel}).
   Guard: facade absent (vocal-highway plugin disabled) → shows a notice and
   auto-advances via setTimeout; never hangs or throws.

4. Update test_bundled_content_loads_clean to expect the 'vocals' path id.

5. Add tests/js/vocal_calibration_handoff.test.js — 4 tests covering:
   - vocals.json shape (id/icon/5 levels, namespaced challenge ids)
   - INSTRUMENTS includes vocals
   - fallback when facade absent (no hang/throw, auto-advance)
   - facade.launch called with correct args, onDone resolves wizard

Facade contract: window.feedBack.vocalCalibration frozen {version:1,
launch({requester,onDone,onCancel})} — built by Dwight (vocal-highway).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
2026-09-03 13:57:19 +02:00
gionnibgudandGitHub eef58c88c3 feat(sloppak): core reader for source rigs (feedpak 1.18.0) (#1040)
ship-ci / ci (push) Has been cancelled
* Carry rig bindings through the sloppak tone payload

`sloppak_tone_changes` emitted `{t, name}` only, so a chart's declared
sound never reached the client: `base_rig` was never read and each
change's `rig` was dropped at the wire boundary. Both survive load
intact (`Arrangement.tones` is an opaque passthrough) — the strip
happened here, at the last step before send.

That left the rig model (feedpak-spec 1.18.0 §6.9/§7.9) unreachable
from core: a pack could declare which rig voices a part, and nothing
downstream could ever see it. First step of the core reader for source
rigs; the rig library itself and the manifest precedence cascade follow.

Return `(base, base_rig, changes)` and keep `rig` on each change. Both
ids are validated as non-blank strings and stripped — anything else is
dropped rather than forwarded, so presence of the key means the change
binds a rig. Resolution against `rigs.json` deliberately does NOT happen
here: this builder preserves the declared binding, while realization
selection and the `intent.gm` fallback belong to whatever voices the
part.

On the wire `base_rig` is omitted entirely when empty, so packs that
bind no rig produce the byte-identical `tone_changes` message they
always did.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

* Load the pack's rig library from the manifest

feedpak 1.18.0 lets a chart declare what a MIDI part should sound like
by binding a rig id, but core had nothing to bind to: `rigs`, `base_rig`
and `drum_tones` appeared nowhere in lib/, server.py or static/. The
preceding commit carries the reference onto the wire; this adds the
library it references.

Read the manifest `rigs:` key into a new `LoadedSloppak.rigs`, alongside
the other side-files rather than on Song — every side-file (drum_tab,
song_timeline, keys, notation) hangs off the load result, and rigs is
pack-level, not per-arrangement. Same permissive posture as its
neighbours: missing, unreadable, malformed or traversing disables rigs
with a warning and never fails the pack, which §7.9 requires outright.

Rig objects pass through VERBATIM. §7.9 obliges a Reader to preserve
unknown role/engine/kind values and `ext` namespaces, so validating
block structure here would be wrong as well as premature — realization
selection and the `intent.gm` floor belong to whatever voices the part.
The only entries dropped are ones unreachable by construction: a rig is
addressable solely by `id`, so a non-dict entry or one without a usable
string id can never be referenced. Ids are stripped to match the
reference side, and a duplicate id resolves first-wins with a warning,
since ambiguity there would surface as the wrong sound rather than an
error.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

* Resolve which tones block binds a part

feedpak 1.18.0 lets a sound binding arrive from three places, and core
honoured none of them: the manifest arrangement entry, the arrangement
JSON, and the top-level drum_tones. Reading them needs a precedence
rule, because two of the three can be present at once.

Arrangement entries: the entry's `tones` replaces the arrangement JSON's
WHOLESALE (spec 5.2), unlike name/tuning/capo/centOffset beside it,
which override field by field. A merge would produce a sound nobody
authored -- one source's base under the other's changes -- which is
worse than either block alone. This is also what makes a notation-only
keys entry bindable at all, since it has no arrangement JSON to carry
tones in the first place.

Drums: the top-level drum_tones binds the song-level primary part, and
a `type: drums` entry's own tones takes precedence, with a Reader
forbidden from applying both to the same part (5.1). That is the same
shape as the drum_tab alias rule, so it lives inside
_resolve_drum_parts next to it rather than beside it -- one precedence
resolver, not two that drift. drum_tones is the PRIMARY's fallback
only: a second drummer with no binding gets None, never the primary's
kit.

An empty `tones: {}` reads as absent rather than as an override to
silence, matching how arrangement_from_wire already normalizes the
in-JSON empty dict, so a stray empty object cannot quietly unbind a
part.

Spec-conformance gate passes with drum_tones added to the keys core
reads (22 of the spec's 32, all declared).

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

* Document the rig bindings on the tone_changes wire message

CHANGELOG entry for the core rig reader, plus the WS protocol table in
CLAUDE.md, which described `tone_changes` as carrying only base + name.

While in that row: its time key was documented as `time`, but every
producer emits `t` — both the sloppak builder and the legacy XML path.
The 3D highway already carries a comment warning readers about exactly
this discrepancy. Corrected here rather than left sitting next to the
newly-added keys, where a reader would reasonably assume both were
equally reliable.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

---------

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-23 11:27:21 +02:00
gionnibgudandGitHub 1a7e2bf084 Extract the pack-path containment guard into one helper (#1039)
Every manifest key that names a file carried its own copy of the same
traversal guard: resolve, prove containment under source_dir, warn and
skip on ValueError, warn and skip on OSError. Seven copies —
original_audio, drum_tab, arrangement, notation, song_timeline, lyrics,
keys — which is seven chances for the next side-file to get a security
check subtly wrong by copying the wrong neighbour.

Route them all through `_resolve_pack_path(source_dir, rel, label)`.

Deliberately preserved, because each was load-bearing:

- Both exception branches, with their different messages. ValueError
  means the path resolved outside the pack (a crafted or broken
  manifest); OSError means it could not be resolved at all (symlink
  loop, permissions). They send an operator to different places.
- Per-call-site control flow. The helper returns `Path | None` and says
  nothing about what to do next, so the two sites that return, the one
  that continues, and the four that fall through to an `is not None`
  test each keep the shape they had.
- The existence-check asymmetry. Some sites test `.exists()` (or
  `.is_file()`) after resolving and some do not, which is intentional —
  a missing optional side-file is silent, a missing arrangement skips an
  entry — so existence stays out of the helper entirely.

Pure refactor: no behaviour change and no new validation. Log output is
byte-identical (the hardcoded labels become a `%s` argument rendering to
the same text). Full suite is unchanged at 2774 passed / 4 skipped
before and after, and the five loader-level traversal tests that used to
cover five separate copies of the guard now all exercise the same
function.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-23 11:26:35 +02:00
32c00cdd78 fix(count-in): follow the song's meter and its pickup measure (#1029)
The count-in always clicked exactly four beats, so a 3/4 song was counted
in 4/4, and a song opening with a pickup (anacrusis) had the pickup enter
where the downbeat belonged — putting the player a beat ahead all song.

Bar length now comes from the song_timeline beats already on the highway
(measure >= 0 marks downbeats), so no new plumbing: the time_signatures
map is streamed to plugins rather than stored in the frontend. A first bar
shorter than that meter shortens the count by its length — a 1-beat pickup
in 4/4 counts "1 2 3" and the music enters on 4.

Bar length is the mode of the downbeat gaps, not the first gap, so a
pickup's own short gap can't be read as the meter; the beats trailing the
last downbeat count as a candidate too, or a song of pickup + one bar
offers only the pickup's gap. Pickup shortening is scoped to the song's
first bar — a short bar elsewhere is a meter change, and is counted by its
own length instead. Songs without beats (pre-chart, minigames, synthetic
highways) still get four.

Applies to both count-in paths: loop wrap / section practice, and the
start-of-song 'Countdown before song' setting.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:26:32 +02:00
8297afc449 feat(tools): per-platform VST3 slicing for rig content packs (#1025)
ship-ci / ci (push) Waiting to run
Rebased onto merged main (was stacked on #1023/#1024, whose venue work is
now in main) so it no longer carries a stale content_packs.py that would
revert 1023's build_pack fixes.

- build_vst_pack: slice a fat .vst3 tree to one platform (keep its binary
  dir + shared bundle files, drop the two foreign platform dirs and src/
  build trees). Pins create_system=3 like build_pack — without it the same
  tree hashes differently on a Windows runner (native .vst3 are built there),
  breaking the precomputable-hash guarantee exactly where it matters.
- Publish wiring: 'python tools/content_packs.py <vst-root> --vst --version N
  --publish' builds+uploads vst-<plat>-vN releases for mac/win/linux and emits
  a platform-keyed {url,sha256,bytes} manifest — the shape rig_builder's
  data/vst_packs.json consumes. publish() refactored onto a shared
  _publish_release helper (venue behaviour unchanged).
- Tests: slice keeps target+shared/drops foreign, per-platform binary,
  reproducibility, unknown-platform reject, and a simulated-win32 guard that
  fails if the create_system pin is dropped. selfcheck covers the VST path.

Original build_vst_pack by Matthew Harris Glover; reworked for the create_system
fix, publish wiring, and rebase.

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:01:51 +02:00
59bcf338a3 feat(career): host higher venues as opt-in content packs (#1023)
* feat(career): host higher venues as opt-in content packs

Move the club and arena venue packs (~678 MB of crowd MP4s) out of the
bundle and download them on demand, keeping the bar starter bundled so
career still works offline. Leans on career's existing pack pipeline
(_download_pack: stream -> sha256 -> extract -> validate -> swap), which
already degrades gracefully when a pack is absent.

- venues.json: club/arena gain `pack` URLs pointing at per-pack, versioned,
  immutable releases (venue-<id>-v<N>, matching the existing venue-arena-v1).
  Arena's sha256/bytes are the real published asset (verified end-to-end);
  club is a placeholder until its release is published.
- tools/content_packs.py: reusable, reproducible pack build/publish/manifest
  tool. Byte-identical output for identical media (fixed order/mtime/perms,
  STORED) so a pack's hash can be known before upload. --local (file://) for
  offline tests, --publish for the per-pack release. Has a --selfcheck.
- .github/workflows/content-packs.yml: workflow_dispatch automation that
  builds/publishes packs and opens the venues.json manifest-bump PR, so
  publishing is never a manual checklist.
- test: round-trips a tool-built pack through career's real _download_pack.

Part of the nightly-slimming effort (feedBack-desktop#122). The desktop
bundle change (stop shipping club/arena) is a companion PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(career): don't offer a venue pack until its release is published

A committed venues.json entry carries a 0-byte placeholder (and all-zero sha)
until its release exists. Previously has_pack was true as soon as a `pack`
object was present, so the UI showed a "Download" button that could only fail
(the placeholder URL 404s). Gate on a real, publish-stamped size via
_pack_published(): the card shows "coming soon" and the download endpoint 404s
until the pack is actually published. Caught by a real bundle+runtime smoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(content-packs): address CodeRabbit review on #1023

- workflow: stop interpolating dispatch inputs into Bash (template
  injection flagged by zizmor). Pass venues/version via env, validate
  formats, use an argument array.
- content_packs: reject top-level files the career downloader would
  refuse (PACK_FILENAME_RE) before publishing — a stray .DS_Store would
  otherwise ship and fail _validate_pack_dir for every client. + test.
- content_packs: pin ZipInfo.create_system=3 so packs hash identically
  across Windows/Unix runners (was the documented reproducibility caveat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): note opt-in career venue packs (#122)

Signed-off-by: Matthew Harris Glover <matthew@harrisglover.com>

* docs(content_packs): correct --publish usage in module docstring

--publish is a flag (no tag arg) and publish() deliberately omits
--clobber; the docstring said otherwise.

Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: Matthew Harris Glover <matthew@harrisglover.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-23 00:09:46 +02:00
03e1c1d57e feat(server): session-sync relay WebSocket /ws/sync/{session_id} (#1030) (#1032)
ship-ci / ci (push) Waiting to run
* feat(server): add session-sync relay WebSocket /ws/sync/{session_id}

Cross-device followers (splitscreen's upcoming LAN pop-out mode,
feedBack-plugin-splitscreen#21) need a machine-crossing replacement for
BroadcastChannel — the one link in the follower architecture that cannot
leave the host browser. Chart data already streams per-client over
/ws/highway, so all that's missing is a dumb live-state channel.

Add a fan-out room endpoint: a JSON text frame from one client is relayed
verbatim to every other client on the same session id. No schema, no
history, no persistence — rooms are created on first join and GC'd when
the last socket leaves. The statelessness is deliberate: an idle room is
indistinguishable from a nonexistent one, and a host that crashes and
rejoins the same id resumes publishing to reconnecting subscribers with
no server-side coordination.

Caps for a LAN-exposable port: 16 KB frames (1009), 16 sockets/room and
32 rooms (1013), 120 msg/s sustained / 240 burst per socket (1008),
text-only (1003), session id validated against [A-Za-z0-9_-]{4,64}. An
over-limit socket is closed individually; a peer that dies mid-fan-out
is dropped without wedging delivery to the rest.

Closes #1030

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>

* fix(ws_sync): bound stalled peer sends; cap ws frames at the transport

Review feedback (CodeRabbit on #1032):

- A peer that stops draining its socket left send_text() pending forever;
  since publishers await the fan-out gather, one stalled peer stalled
  every publisher's receive loop behind it. Fan-out sends are now bounded
  by SEND_TIMEOUT_SECONDS (5 s) so a stall becomes an eviction through
  the existing failed-send drop path.

- uvicorn buffers inbound WS frames up to its 16 MB default before the
  handler's 16 KB check ever runs, so the DoS bound wasn't enforced at
  the transport. main.py now passes ws_max_size=64 KB (no client sends
  large frames: the highway WS receives only small control messages, and
  the relay keeps its tighter application cap as the primary limit).

Regression tests for both; the desktop's own uvicorn spawn gets the
matching --ws-max-size flag with the feedBack-desktop follow-up work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>

---------

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:45:56 -04:00
0e3522ccc3 feat(player): drum-part picker for multiple drum charts (re-land of #1021) (#1028)
ship-ci / ci (push) Waiting to run
* feat(player): drum-part picker for multiple drum charts (feedpak 1.17.0)

The last mile of the multiple-drum-parts feature: let a player CHOOSE which
drum chart plays. #1020 taught the loader + highway WS to carry several drum
parts (song_info.drum_parts + ?drum_part=<id> + a part_id echo on drum_tab);
this adds the host-chrome selector that drives it.

A "Drum part" <select> sits beside the arrangement switcher in the advanced
settings popover, shown only when a song has 2+ drum charts (drum_parts is
always present — empty for non-drum songs — so single-drum / no-drum songs
hide the row and nothing changes for them). Selecting a part re-streams that
part's tab over the highway WS, exactly like an arrangement switch.

- static/highway.js:
  - reconnect() gains a third `drumPart` arg → sets `?drum_part=<id>` on the WS
    URL (mirrors the existing `arrangement` param one line up). Empty/undefined
    → the primary part, i.e. byte-identical to today for any pack untouched.
  - song_info handler populates #drum-part-select from msg.drum_parts and
    shows/hides #v3-drum-part-row on `length > 1` (parallel to the #arr-select
    block right above it).
  - drum_tab handler carries msg.part_id onto hwState.drumTab (plugins can read
    bundle.drumTab.part_id) and reflects it as the picker's selected value, so
    the dropdown stays honest even when the server resolves an unknown/absent
    selection to the primary.
- static/app.js:
  - changeArrangement() gains an optional `drumPart`; at reconnect it forwards
    the explicit part, else preserves the current picker selection — so an
    ARRANGEMENT switch keeps the chosen drum part (parts are song-level).
  - new changeDrumPart(id) delegates to changeArrangement with the current
    arrangement held + the new part applied (a part switch is the same
    re-stream, so it reuses all the transition ceremony). Exported on window.
- static/v3/index.html: the #drum-part-select row (hidden by default).

No plugin change: the drum renderers just draw whatever drum_tab streams.

RUNTIME-VERIFIED (Playwright, the core player, a 2-drum pack + a no-drum pack):
10/10 — the picker populates with both parts and shows for the multi-drum song;
song_info.drum_parts reaches getSongInfo(); the primary is pre-selected;
selecting the 2nd part drives highway.reconnect with the id and the WS URL
carries `?drum_part=drums-2`; the picker then reflects the server's part_id
echo; a no-drum song hides the row; no page errors. ESLint 0 errors (the two
max-lines warnings are pre-existing on these files). No pytest touched (JS-only).
Stacked on #1020.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Update reconnect source contract test

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:35:08 +02:00
e0270e5c30 fix(song): make bass detection instrument-type-aware, not name-only (#1019)
ship-ci / ci (push) Waiting to run
Editor now authors an arrangement's instrument as first-class data (a manifest
'type' field). Core dropped it: the sloppak loader never read 'type', and
'is this a bass?' was defined three different ways across call sites (name-only
in note_pitch_midi and the highway scale-degree path; path_bass+name in bass
selection; name-only in arrangement_string_count). So an authored type=bass
chart not named 'bass' got 6-string lane counts and guitar open-string MIDI.

- Add optional Arrangement.type; sloppak load_song lifts the manifest type onto it
- Add arrangement_is_bass(arr) = type=='bass' OR path_bass OR 'bass' in name
  (None/whitespace safe), and route string count, note_pitch_midi, the highway
  scale-degree base, and bass-player selection through it
- Back-compat: no bass signal -> unchanged 6-string / guitar behavior

Companion to editor #335 (first-class instrument type). Scale degrees are
display-only and never feed a grader.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:30:54 +02:00
605dbdfd25 feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements) (#1020)
* feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements)

A song can now ship SEVERAL drum charts (a second drummer, an aux-percussion
layer). The Arrangement Editor already writes them per the feedpak 1.17.0 FEP
(feedpak-spec#63): the primary stays the song-level `drum_tab:` key (what this
app has always played), and each part rides the manifest as a `type: drums`
arrangement entry carrying a per-arrangement `drum_tab` file pointer and NO
note `file` — an entry this loader's file/notation gate already skips, which
is exactly why old builds are unaffected by such packs.

lib/sloppak.py:
- The arrangements loop collects drum-part pointer entries instead of merely
  skipping them — but still NEVER turns one into a fretted Arrangement. That
  skip is the grading invariant (an empty drum chart must not reach the
  fretted pipeline / note-detection grading) and is now pinned by test.
- New `LoadedSloppak.drum_parts`: [{id, name, drum_tab}], primary FIRST. The
  entry aliasing the song-level file contributes its id/name but is never
  loaded twice (the primary's payload IS `loaded.drum_tab`, same object).
  Legacy single-drum packs read as a one-part list; a pointer-only pack (a
  writer omitted the alias) promotes its first part so has_drum_tab, the
  default stream, and the drum-only placeholder keep working.
- The song-level drum_tab loading block is extracted verbatim into
  `_load_drum_tab_file()` and shared by both paths, so every part gets the
  same permissive posture: missing file → that part silently absent;
  traversal / parse / validation failure → that part skipped with a warning,
  never an aborted load. (The 9 pinned drumtab-load tests pass unchanged.)

lib/routers/ws_highway.py:
- `song_info` gains `drum_parts` (names only; always a list, empty without
  drums) so a part picker can bind unconditionally.
- `?drum_part=<id>` on the WS URL selects which part's tab streams as the
  `drum_tab`/`drum_hits` messages; the default and any unknown id fall back
  to the primary — byte-identical legacy behavior. The `drum_tab` message
  carries `part_id` only when a parts list exists, keeping the legacy frame
  unchanged.

Tests: tests/test_sloppak_drum_parts.py (9) — the grading invariant +
parallel-ids pin, primary-first resolution with alias identity, legacy
one-part list, pointer-only promotion, per-part failure isolation (bad JSON,
path traversal, duplicate rels), and the drum-only placeholder with pointer
entries. Full suite: the only failures are 9 machine-environmental tests
(installed desktop plugins under LOCALAPPDATA, CRLF/path-shape assertions)
that fail identically on an untouched origin/main checkout on this box.
tools/check_spec_conformance.py passes against the spec's current HEAD
(`drum_tab` and `type` are declared keys); the semantics of the
per-arrangement placement land in feedpak-spec#63 — this PR should merge
after it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Fix drum-part review findings

* Normalize drum part pointer identities

* fix(sloppak): enforce drums grading invariant + green the suite

- Gate the drum-pointer skip on type FIRST: a type:drums/drum entry never becomes
  a fretted Arrangement even if it carries a note file/notation (with drum_tab it
  is collected as a drum part, without it dropped+warned). Closes the spec
  §5.2/§7.5 MUST-NOT hole (a malformed drums+file entry was being fretted-graded).
- Make test_drum_pointer_with_wrong_type_logs_warning robust (attach handler to the
  feedBack logger + set WARNING, restore in finally) and fix the root-cause level
  leak in test_tuning_provider_isolation.py (finally restored the handler but not
  the level, leaking ERROR onto the feedBack tree and turning the suite red under
  full ordering).
- Restore the chart-transform CHANGELOG bullet (#952) the drum entry had truncated.

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: byrongamatos <xasiklas@gmail.com>
2026-07-21 13:27:41 +02:00
a9be210f77 fix(highway_3d): Venue desync, bind race, and a11y for the player background control (#1018)
ship-ci / ci (push) Has been cancelled
* Fix 3D Highway background controls under Venue override

When the Venue visualization override is active, the entire Background control group (style dropdown and intensity/reactive knobs) is now disabled since the venue scene owns rendering. The dropdown, intensity, and reactive controls are greyed out with a tooltip explaining the state.

Also fixes a cold-load bug where the screen hook subscription could fail to bind if the event bus wasn't ready during renderer init, by re-attempting binding on each retry tick.

Includes test coverage for both the Venue override greyout behavior and the cold-load screen hook binding fix.

* Add accessibility features and explicit global reads to background control

Refactor the player chrome background control to use an explicit `_bgReadGlobal()` function instead of relying on the implicit behavior of `_bgReadSetting(null, ...)`. The control is a single shared instance across splitscreen panels and must always read/write the global slot.

Add accessibility improvements:
- aria-pressed on toggle buttons to expose state to screen readers
- aria-label on select and intensity controls
- aria-describedby pointing disabled controls to a visually-hidden reason span
- The reason span carries dynamic explanatory text for why a control is greyed out

Add comprehensive tests verifying the new `_bgReadGlobal` helper ignores per-panel overrides and that all accessibility attributes are set and updated correctly.

* Gate player control slot on v3 UI version

Add explicit check for `window.feedBack.uiVersion === 'v3'` in _pcSlot() per docs/plugin-v3-ui.md. This prevents the plugin from attempting to mount player controls on non-v3 hosts (e.g., legacy v2 shell). Complements the existing `playerControlSlot` typeof check and improves compatibility robustness.

Updated test mocks to include `uiVersion: 'v3'` and added test case verifying that mounting is skipped when uiVersion is not v3, including a guard to ensure the retry loop terminates properly.

* Clarify 3D highway style control behavior

Document that the style controls group also greyes out when the Venue scene override is active, since the controls don't apply in that mode.

* Restore style dropdown tooltip when Venue override exits

The style dropdown's tooltip was cleared whenever the Venue override was inactive, permanently discarding the "Background style" hint set at mount time. Since the sync runs on every settings change, the tooltip was lost on the first sync and never returned.

This brings the dropdown in line with the intensity slider and reactive toggle, which already restore their base tooltip when they're re-enabled.

Includes a test asserting the tooltip returns after the Venue override exits.

* fix(highway_3d): skip player-control retry loop on non-v3 shells

_pcAcquire only runs once the renderer is viable inside the v3 player
chrome, and player-chrome.js sets uiVersion synchronously as it builds
that chrome — so a missing 'v3' at acquire means v2, not a not-yet-ready
v3. Bail before scheduling the retry loop instead of spinning it out to
the ~3s budget for a slot that will never appear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:18:12 +02:00
35c0d0ea0d Pass per-stem name/description through to the stems payloads (#1013)
ship-ci / ci (push) Waiting to run
feedpak 1.16.0 (spec §5.3) added two OPTIONAL presentational fields to a
stems[] entry: `name` (display label, Readers fall back to the id) and
`description` (free text). The server dropped both while normalizing
manifest stems, so no client could ever display them.

Pass them through at the one place stem descriptors are built
(sloppak.load_song) and let both payload builders — the WS `ready` stems
list and the REST `/api/song/{f}?stems=1` preload list, which are pinned
against each other by test — carry them forward. Omit-when-absent, so a
stem without the fields does not grow null keys; non-string or blank
values are dropped rather than surfaced.

No behaviour change for existing packs or clients: the fields are
additive and every consumer that reads {id,url,default} keeps working
unchanged. The stems plugin / stem mixer display work lands separately.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:45:16 -04:00
270cb39f41 Enable Linux nightly AppImage auto-update in the System settings UI (#999)
ship-ci / ci (push) Waiting to run
* fix(settings): enable Linux nightly AppImage auto-update in System settings

Fixes the Settings → System "App updates" panel so it actually works on
Linux, and adds Nightly as a selectable channel — previously missing
entirely, so Linux self-update couldn't be reached from this UI at all.

- The channel dropdown no longer gets permanently disabled the moment
  the desktop bridge reports 'unsupported', which is the normal state
  whenever the channel isn't Nightly on Linux. It stays enabled so the
  user can switch to Nightly, the only way out of that state.
- Shows live download progress ("Downloading update… N%") and an
  explicit button state machine (Check → grayed out while busy →
  Restart now once staged), instead of a frozen "Checking…" during the
  ~1.5GB background download.
- Renders every status update from the triggering action's own return
  value (checkNow()/setChannel()'s result) rather than a separate
  follow-up getStatus() call, which can race against other state
  changes and show a stale result even after a real success.
- setupAppUpdates() no longer re-syncs the channel to the backend on
  every Settings-panel re-render — only once per page load — so a
  redundant sync can no longer stomp an in-flight download's state.
- Routes update-flow events into the existing diagnostics.js
  console-capture + contribute() snapshot API, so the user's existing
  "Export Diagnostics" button now captures the full update decision
  trace end to end — no new UI or log file. This diagnostic tracing is
  what actually root-caused the bugs above, from real on-device
  captures rather than guesswork.

Companion PR in feedBack-desktop (the underlying update engine).

Verified end-to-end on a Steam Deck: channel switch → check → live
download progress → restart button → relaunch onto the new build,
confirmed via a real Export Diagnostics capture showing a clean,
fully-accounted-for trace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(settings): extract + unit-test the app-update status view; dedupe diag log

- Extract the status→UI state machine from renderFrom into a pure, exported
  _appUpdateStatusView() (DOM-free) and cover it with tests/js — settings.js's
  large module graph made importing it for a full harness impractical, so the
  pure function is the testable seam. Behavior-preserving; renderFrom applies
  the returned shape to the DOM exactly as before.
- Dedupe the [update-diag] renderFrom console line so the ~1.5s download poll
  no longer floods the diagnostics ring buffer with byte-identical entries;
  every real state/percent change still logs, and the structured contribute()
  snapshot stays unconditional.

Left the 'audio_engine' diagnostics key as-is: the server export filters
client contributions to loaded plugin ids (diagnostics_bundle.py path-traversal
guard), so a dedicated key would be silently dropped from the bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 12:16:02 +02:00
23c509322b Add gamepad/controller support (#1001)
* feat(input): add gamepad/controller support

Adds full gamepad/controller navigation and playback control, driven
by requests from players who use fee[dB]ack on a TV/console setup and
from wheelchair users for whom a controller is far more convenient
than a keyboard + mouse. Confirmed working end-to-end on a Steam Deck
across several rounds of on-device testing.

- static/v3/gamepad.js: polls navigator.getGamepads() and dispatches
  synthetic keydown events (Arrow/Enter/Space/Escape) on the focused
  element (falling back to document), reusing the app's existing
  keyboard pipeline (static/js/shortcuts.js's scope-aware dispatcher,
  player shortcuts, text-field/modal guards) instead of a parallel
  action-mapping table. Only acts on gamepads reporting the W3C
  "standard" mapping — which is what Steam Input presents for the
  Deck's built-in controls, both in Gaming Mode and in Desktop Mode
  via a non-Steam shortcut — so button order is guaranteed correct
  and a non-standard/raw device safely no-ops instead of misfiring.
  Handles Steam Input's virtual-pad duplicates (a real controller
  plus 1-2 mirrored XInput slots) without spamming connect toasts or
  losing input when the live pad isn't at index 0. Xbox-style face
  button mapping: bottom face = Space (play/pause, and activates the
  focused control), right face = Escape (back), top face reveals the
  player screen's tool rail (focuses it into visibility via the
  existing CSS :focus-within rule). D-pad/stick repeat while held,
  mirroring OS keyboard auto-repeat.

- static/v3/gamepad-nav.js: fills the one real gap in that reuse
  strategy — no screen but the song library grid had any arrow-key
  navigation, and Chromium doesn't run native Enter/Space button
  activation for untrusted synthetic events even when dispatched at
  the focused element. Gated entirely on `!e.isTrusted`, so it only
  ever reacts to gamepad-originated events and never touches real
  keyboard/mouse users: emulates Tab-order (the sidebar + active
  screen's real, already-focusable buttons/links) for Arrow keys,
  explicitly .click()s the focused element for Enter/Space, and gives
  Escape a consistent "go back" behavior — an existing in-screen back
  button if one's visible (reusing each screen's own drill-down logic
  for free), else the main menu. Every branch defers via
  `e.defaultPrevented` to any screen that already handles the key
  itself (the song grid, the player, settings), so nothing here
  overrides existing behavior.

- static/v3/songs.js: adds real 2D d-pad/arrow-key navigation to the
  song library's virtualized grid (only a slice of the library is
  ever in the DOM), including fetching/scrolling off-screen rows into
  view and correcting for the sticky filter toolbar's occlusion.

- static/v3/index.html: wires up the two new scripts.

* chore: regenerate stale tailwind.min.css

Rebuilt in a fresh clone (not the local working copy). Several plugin
directories (audio_engine, plugin_manager, community_charts, etc.) are
gitignored locally but present on disk from checking out plugin repos
for local dev/testing — Tailwind's content scan picks them up
regardless, so a rebuild against the contaminated local working copy
bakes in extra utility classes that don't belong in the real,
git-tracked build. A clean checkout reproduces CI's expected output
exactly.

* fix(gamepad): check all matching back buttons, not just the first

document.querySelector on the combined [data-ap-back], [data-albums-back],
#v3-pl-back selector only ever inspects the first match in DOM order —
since screens stay in the DOM (hidden, not removed) when you navigate
away, a hidden back button from an unrelated screen could sort before
the one that's actually visible, incorrectly falling through to
showScreen('v3-home') instead of clicking it. Uses querySelectorAll +
find(visible) instead.

* fix(gamepad): address CodeRabbit findings on connect/disconnect and grid nav

- gamepad.js: anyLiveConnectedPad -> anyLiveStandardPad, filtering by
  mapping === 'standard' like firstLiveStandardPad already does, and
  applied at the top of the gamepadconnected handler too. A still-
  connected non-standard raw mirror could otherwise mask the real
  pad's disconnect (toast never fires, polling never stops).

- songs.js _gpMove: an unset cursor now always seeds at index 0
  before the first press, instead of applying that press's delta
  immediately (ArrowDown/Right previously skipped straight past row
  0; Left/Up only looked right by accident of clamping). Matches the
  existing convention in shortcuts.js's legacy _handleLibArrowNav.

- songs.js _gpBlockedTarget: form-control/button blocking now
  requires the element to be visible (offsetParent !== null), not
  just present. Screens stay in the DOM hidden (not removed) when you
  navigate away, so a real button focused on some other now-hidden
  screen could leave document.activeElement pointing at it and block
  all grid navigation indefinitely. (An el.closest('#v3-songs') scope
  was tried first and reverted — it fixed that case but broke
  blocking for the topbar search input, which lives outside
  #v3-songs's DOM subtree even while v3-songs is active; visibility
  is the distinction that actually matters, not DOM nesting.)

Skipped two CodeRabbit suggestions, verified against current code:
gating songs.js's grid keydown listener to synthetic-only events
would regress the real keyboard accessibility this PR intentionally
added (v3-songs' grid had none before); renaming the _gp* helpers to
drop their underscore prefix would break from this codebase's own
established module-private naming convention.

Verified in-browser: first arrow press lands on index 0, stale hidden
focus no longer blocks grid nav, the topbar search input still
correctly blocks it, and normal nav resumes after blur.

* test(gamepad): unit-cover the controller + nav state machines

- gamepad.test.js (10): standard-mapping filter, Steam Input duplicate-slot
  dedup, disconnect masking, button edge-detection, d-pad/stick repeat timing,
  analog deadzone — driven via a fake navigator + manual rAF queue.
- gamepad_nav.test.js (10): !isTrusted/defaultPrevented gating, arrow focus
  traversal + clamping, hidden-element skipping, Enter/Space click activation
  (not into text fields/body), Escape visible-back-button vs home fallback.

songs.js grid nav is left to on-device coverage (async + windowed-DOM heavy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:52:51 +02:00
fcdb4867d6 feat(highway_3d): background controls in the player chrome (#1008)
* Add mid-song background picker to player chrome

Mount a background style/intensity control in the player's plugin popover so users can switch backgrounds mid-song without leaving for Settings. Uses ref-counting to manage the shared control across multiple renderer instances. The control syncs bidirectionally with settings.html and the settings bus, so changes from either UI stay agreed. Moved _pcAcquire() to after _isReady to avoid acquiring for non-viable (e.g. WebGL2-missing) renderers.

* Grey out background controls that current style ignores

Add _PC_USES table to track which settings (intensity, reactive) each background style actually consumes. Disable and grey out controls when the active style doesn't use them, preventing user confusion. Updates _pcPaint() to support disabled state with tooltip explanations, and guards click/change handlers against disabled controls.

* Add background control tests and changelog entry

Document the new background controls feature in the 3D Highway plugin that allows changing the highway background mid-song from the player's Plugin Controls popover. Add a comprehensive test suite for the background control system covering refcounting, settings sync, greying out unsupported controls, and teardown behavior.

* Generalize background control refcounting language

Update CHANGELOG and test comments to reflect that the 3D highway background control refcounting applies to any multiple renderer instances, not exclusively splitscreen. Change test name and clarify that multi-instance behavior is exercised with stubbed instances, not real splitscreen sessions (whose visualizer does not currently work).

* Reorder 3D Highway changelog entry, bump version

Moved the 'Background controls in the player' entry to a different position in the Unreleased changelog section. Updated 3D Highway plugin version from 3.32.0 to 3.33.0.

* fix: store screen.js and CHANGELOG.md with CRLF to match main

The merge of main was run with merge.renormalize=true (needed — this repo has CRLF committed while core.autocrlf=true, so a plain merge sees all 16k lines as changed). That rewrote screen.js and CHANGELOG.md to LF, which autocrlf then stored. main has both as CRLF, so every line differed and GitHub reported 16,428/16,112 for screen.js and refused to render it.

Restaged with the CRLF blobs written directly so they are what get stored. No content change; the diff drops to 316/0 and highway_3d_render_order.test.js leaves the diff entirely.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>

* Unbind screen:changed hook on last release

Ensure the highway_3d control removes its screen:changed listener when the last reference is released to avoid listener/closure leaks across plugin reloads. Added a best-effort off() call and clears _pcScreenHook so future acquires re-bind correctly. Tests updated: mock feedBack on/off implemented, helpers added (screenHooks, fireScreenChanged), and a new test verifies the subscription is removed on final _pcRelease and re-subscribed on re-acquire.

* fix(highway_3d): show greyed-out reason on hover for disabled bg controls

A native-disabled <button>/<input> receives no pointer events, so its
`title` tooltip never appears — the "greyed out, says why on hover"
affordance was dead in the browser while the tests passed on the
swallowed control title. Move the reason onto a non-disabled wrapper and
set pointer-events:none on the disabled control so the hover reaches it.
Also add aria-disabled so screen readers get the state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:39:26 +02:00
be49465540 fix(highway_3d): initialize camera before silent intros (#1002)
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 11:38:25 +02:00
05be9ebdbe Add new chart-transform plugin capability (#1000)
* Chart-transform plugin capability

* PR comments

* Cleanup

* Fix markdown

* CodeRabbit feedback

Signed-off-by: Joe <jphinspace@gmail.com>

---------

Signed-off-by: Joe <jphinspace@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 11:27:52 +02:00
f7942f3689 fix(gp8): confine registry asset matching to the declared directory (#1011)
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
same recording can win — an `.ogg` beside the declared `.mp3` is copied out
losslessly rather than transcoded. But the search spanned every directory in
the archive, so an unrelated file that merely shared the stem could stand in
for the declared asset: exactly the substitution the registry lookup added in
#1007 exists to prevent.

Candidates are now confined to the registry path's own directory. A genuinely
absent asset still falls through to the legacy stem match and then the first
audio asset, as documented.

Found by an adversarial pass over #1007 rather than a report — no known file
triggers it, since GP8 writes embedded audio to Content/Assets/ and that is
the only directory scanned. It needs a hand-edited archive to reach.

Both tests fail on main and pass here; their ZIP ordering is deliberate, so
the fall-through target differs from the decoy (otherwise fixed and unfixed
code return the same file and the tests prove nothing).


Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:36:28 -05:00
1cd6f2dd65 fix(gp8): AssetId is a key into <Assets>, not a filename stem (#1007)
* fix(gp8): AssetId is a key into <Assets>, not a filename stem

GPIF declares the backing track's audio as:

    <BackingTrack><AssetId>0</AssetId>
    <Assets><Asset id="0">
        <EmbeddedFilePath>Content/Assets/<hash>.mp3</EmbeddedFilePath>

so AssetId indexes the <Assets> registry, which names the exact path in
the ZIP. `_resolve_audio_asset` instead compared it against each audio
file's FILENAME STEM. GP8 names embedded files by hash while ids are
small integers, so that match essentially never hit: every such file
logged "declared AssetId not found" and fell through to "first audio
asset". Silently correct while a file carries exactly ONE audio asset —
but with two, a backing track declaring id 1 resolved to asset 0, i.e.
the wrong recording, for both extract_sync and extract_audio.

Found while verifying embedded-audio extraction for a reported GP8
import; that file logged the warning on the normal path.

- `_asset_path_from_registry()` reads <Asset id=N><EmbeddedFilePath>,
  normalising separators (a writer may emit backslashes). It never
  decides a path exists — the caller verifies membership in the archive,
  since the value comes out of the file and a stale entry must fall
  through rather than resolve to nothing.
- Resolution is now a ladder: registry → legacy stem match → first audio
  asset. Steps 2 and 3 are the previous behaviour, kept so existing
  files and odd shapes are unaffected. Same-stem OGG preference is
  preserved on the registry path too, so quality behaviour is unchanged.

Tests: registry resolution on the real-world shape (integer id, hashed
filename), the second asset finally being reachable (the actual bug), a
registry entry pointing at a missing file falling through, backslash
normalisation, OGG preference among same-stem duplicates, malformed and
absent registries degrading, and the legacy stem match still working.
Suite 1725 passed vs 1720 on main, same 99 pre-existing env failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01929LgKdJMyPGLf8N1WpEVW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* docs(changelog): record the GP8 AssetId resolution fix

Every other change in this release notes itself; this one shipped without
an entry, and the GP import path has had three fixes in two days — the
history is worth being able to read later.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:19:59 -05:00
K. O. A.andGitHub 39d1a8cb9b feat(v3): one-click "Not split" library filter + piano stem pill (#1010)
Finding un-split songs took five taps (cycle each stem pill to its
"lacks" state) — and was quietly wrong even then: the drawer offered
five of the canonical six stems, so a piano-only song lacked all five
listed and matched a hand-built "not split" filter despite being split.

The stems section gains a "Not split" toggle that sets stem_lacks to
every instrument stem in one tap (the same lacks-ALL query Stem
Splitter's missing-stems view runs, backend semantics unchanged), and
piano joins the pill row (already in the backend's allowed set).

(Rebuilt on current main after #1003/#810/#92e78be rewrote the drawer
region — the original branch conflicted whole-file.)

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-19 02:13:42 -04:00
32ed564006 fix(gp2rs): write arrangement XML as UTF-8 so GP import survives non-ASCII metadata (#984)
The GP→arrangement-XML writers persisted their output with
`Path.write_text(xml_str)` and no explicit encoding. On Windows that uses
the cp1252 default, so a non-ASCII metadata character — e.g. the © in an
album name like "Chrysalis©1982" — was written as the lone byte 0xA9.
The XML is read back as UTF-8 (expat's default), where 0xA9 is an invalid
start byte, so `parse_arrangement` died with:

    xml.etree.ElementTree.ParseError: not well-formed (invalid token): line 10, column 22

and the whole Guitar Pro import failed (HTTP 500). All three arrangement
XML writes (gp2rs.py, gp2rs_gpx.py ×2) now pin encoding="utf-8".

CI runs on Linux (UTF-8 default) so the bug was invisible there; the new
test pins the locale-independent contract at the source level plus a
round-trip of a © album name.


Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:56:32 -05:00
1712803dc7 feat(notation_lift): authored per-note hands steer the split — heuristic only guesses the rest (#992)
The keys LH/RH hand arc, the lift slice. split_hands was purely
heuristic (mean pitch vs middle C / largest-gap), so ANY edit to a keys
arrangement re-derived hand splits that could contradict the score's
authored grand-staff assignment — the documented "produces wrong hand
splits" failure, now that authored hands actually reach the wire
(editor #299 emits per-note `hand`; core #990 round-trips it).

- decode_wire_notes carries `hand` through ('lh'/'rh' strict enum;
  junk → None so a hand-edited pack can't steer the split).
- split_hands: an authored hand always wins, and explicit notes are
  REMOVED from their simultaneous group BEFORE the heuristic math runs
  — one authored assignment must never skew its chordmates' guesses
  (e.g. an authored LH melody note above middle C dragging the group
  mean down and flipping the rest). All-explicit groups skip the
  heuristic entirely; unassigned notes behave exactly as before.

Design per the piano-pedagogy review of the arc: binary lh/rh + absent
= unassigned; per-note explicit > heuristic precedence; crossing-hands
textures are exactly why the override is load-bearing.

Tests: five new in test_notation_lift.py (authored wins incl. a
crossing-hands case, group-removal-before-math with exact mean
arithmetic, all-explicit group, junk enum, decode carry-through); the
decode shape pin updated for the new key. Suite: 1723 passed (+5 vs
main; the pre-existing env failures reproduce identically on pristine
main).


Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:40:08 -05:00
00fce2772d feat(song): per-note keys hand assignment (hand) on the Note wire (#990)
The editor's keys LH/RH hand arc needs a per-note hand assignment
('lh'/'rh', from a MusicXML grand staff import today, hand-editable
later) to survive a sloppak save → reload: the editor already emits it
on the wire, but note_from_wire dropped unknown keys, so the field died
on every reopen.

- Note gains `hand: str | None = None` (None = unassigned; the
  heuristic hand split keeps owning unassigned notes). Distinct from
  `right_hand` (the bass plucking finger) — hence the spelled-out
  `hand` wire key, since `rh` is taken.
- note_to_wire emits it default-omitted and validates on emit; older
  readers ignore it (feedpak: unknown note keys are permitted).
- note_from_wire decodes it as a strict enum — anything but 'lh'/'rh'
  (junk, wrong case, bools) falls back to unassigned rather than
  poisoning downstream hand-split / hands-separate practice logic.

Groundwork consumers land separately: notation_lift.split_hands
respecting per-note overrides, and the editor's hand surface.
Editor counterpart: feedBack-plugin-editor #299.

Tests: three new wire round-trip tests in tests/test_song.py (literal
key, default-omitted, junk rejection both directions), matching the
teaching-marks test style.


Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:39:27 -05:00
1745b13ba7 feat(playlists): flag songs that are not in your current tuning (#1009)
* feat(playlists): flag songs that are not in your current tuning

Making the library's tuning filter instrument-aware does not repair playlists
already built under the old guitar-first behaviour. Those keep their
wrong-tuning songs, so a player still hits a surprise retune mid-practice and
reasonably concludes nothing was fixed.

Adds a per-playlist check: each row is marked against the player's current
tuning, with a summary ("3 of 24 songs are not in your tuning"), a filter to
show only those, and an explicit removal that lists every affected song by
title and states they stay in the library. Flagging is the feature -- nothing
is ever removed without being asked for, and removal reuses the existing
per-song DELETE rather than adding a bulk destructive endpoint.

Reuses the tuner capability's coverage report and `window.feedBack
.workingTuning`, the same pair the library cards already score against,
rather than introducing another source of truth.

Two deliberate departures:
- A coverage report reads "not covered" both for a real mismatch and for a
  bail-out it could not evaluate. Only a report carrying an actual reason
  counts as a mismatch; the rest render as unknown. This differs from the
  library grid, which paints every not-covered song amber -- acceptable on a
  grid, not on a hand-curated playlist where a false warning costs trust.
- With no tuning perspective available it makes no claim at all, rather than
  defaulting to guitar and reproducing the original bug in a new place.

Playlist rows carry `tuning_offsets` and `bass_only`; a tuning *name* cannot
be scored, since two "Custom Tuning" rows are different tunings.

Fully correct once the instrument-aware tuning filter lands. That dependency
is confined to `rowTuningForCheck()` in static/v3/playlists.js, marked SEAM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* build(tailwind): regenerate for the playlist tuning-check classes

CI's tailwind-fresh gate rebuilds static/tailwind.min.css and hard-fails if
the committed file differs. The new chip/summary/filter markup introduces
classes the previous build never saw.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(playlists): stay within the shipped Tailwind class set

Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.

Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.

Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.

The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Use instrument tuning in playlist checks

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:10:53 -05:00
cc75cb876a fix(library): tuning filter answers for your instrument, not always guitar (#1003)
* fix(library): tuning filter answers for your instrument, not always guitar

The library indexed exactly one tuning per song, chosen guitar-first (lead >
rhythm > combo, bass only as a last resort), and nothing consulted the
player's instrument. A bassist filtering by tuning was shown the guitar
chart's tuning, so playlists built by tuning contained songs needing a
retune. Reported by a tester building bass practice sets; Covet "Shibuya" is
the clean case, with a custom guitar tuning over a standard bass chart.

Indexes each arrangement role's own tuning and makes the facet, filter, sort
and labels answer for one perspective. `guitar-lead` reads the original
unprefixed columns and adds no payload keys, so the default response is
unchanged. The same defect existed inside guitar -- lead and rhythm charts
can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm,
bass) driven by one PERSPECTIVES table rather than parallel column families.

Songs with no chart for the perspective fall back to the song-level tuning
rather than vanishing (18 of 59 packs in the test library have no bass
chart), but the fallback is marked inferred in the facet counts and on the
row instead of being silently coalesced. "Only real charts" reuses the
existing `arrangements_has` filter rather than adding one.

Bass-specific handling, from measured content:
- Bass tuning arrays are padded to six entries; charts never reference
  string index 4 or 5. Truncated to four before naming and grouping.
- Grouping uses a canonical open-pitch key, so [-2,0,0,0] and
  [-2,0,0,0,0,0] are one facet row instead of two.
- Offsets above +1 semitone are refused a name. Bassists tune down, near
  never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own
  notes sit in the song's real key under standard tuning). Naming that
  would send a player to retune to a tuning that does not exist.

Rhythm deliberately does not truncate -- padding is a bass finding, and
cutting a seven-string array would invent a tuning the chart lacks.

Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is
offered when your lowest open pitch is at or below its lowest open pitch, so
a five-string bass covers four-string standard and drop-D with no retune.
Open strings only -- note range is not indexed and the scan stays
manifest-only -- so it fails conservative: unknown low pitch is excluded, and
the upper bound is unchecked and documented rather than guessed.

Existing installs would otherwise never populate: the tree-signature fast
path reports "unchanged" forever on a settled library. Rows with NULL marker
columns re-extract, and the fast path is disabled until that backfill
converges (writes use '' rather than NULL, so it self-clears).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* test(v3): accept the tuning-perspective indirection in the badge guard

The album-art badge now reads shownTuningName(), so the source-pattern guard
no longer matched the inline `tuning_name || tuning` form and CI went red.
Accept the helper, and pin the helper's own fallback in a companion test so
the guard still fails if a guitar player's tuning label is ever dropped.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:30 -05:00
f0d9c3abc0 feat(playlists): manual drag order + Sort A-Z for the playlist list (#1004)
Playlists could only ever be listed alphabetically (system playlists first).
Users who group playlists by purpose had no way to put the ones they reach
for daily at the front.

Adds a nullable `position` column and orders by
`(system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE`,
so manually-ordered playlists lead, unpositioned ones keep sorting
alphabetically behind them, and system playlists stay pinned first.

Drag-reorder mirrors the existing within-playlist song reorder, adapted for
grid tiles (insert side decided on the horizontal midpoint since tiles flow
left-to-right then wrap). System playlists are neither drag sources nor drop
targets. `POST /api/playlists/reorder` requires an exact permutation of the
current non-system ids, so a duplicate, omission, extra, unknown id, or a
system id is rejected rather than silently producing duplicate positions;
booleans are rejected explicitly because `sorted([True, 2]) == sorted([1, 2])`
would otherwise slip through the permutation check.

`POST /api/playlists/sort-alpha` clears the manual order again.


Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:23 -05:00
K. O. A.andGitHub 2413991c5a feat(highway_3d): fret wires flash on a confirmed hit (#969)
ship-ci / ci (push) Waiting to run
* feat(highway_3d): fret wires flash on a confirmed hit

The fret wires were static scenery: gold inside the anchor lane, grey
outside, and nothing tied them to what the player was actually doing.

Give them a job. Widen the lane/neck contrast so the wires around the
active lane read as a focus cue, and flash the wires bracketing a note
when a scorer confirms it. A fretted note lights the wire behind it and
the wire it is pressed against; a chord lights only the outermost wires
of its shape, so it reads as one bracketed block rather than a picket
fence; an open string has no fret of its own and its gem is drawn as a
slab spanning the lane, so it lights the lane's edge wires instead.

Gated on the provider verdict, never the proximity heuristic -- the
latter only means "near the strike line", so it would flash on every
passing note whether or not it was played. With no scorer attached the
neck behaves exactly as before.

Emissive (and emissiveIntensity) carry the flash, not albedo: these are
MeshStandard materials in a scene with no envMap, so raising albedo
alone barely brightens them.

Every value is a named constant -- see FRET_WIRE_* -- because the look
is a taste call that wants tuning by eye, not a derivation.

Signed-off-by: Kris Anderson <topkoa@gmail.com>

* feat(highway_3d): cap the fret-wire flash at one outer pair

Fast passages overlap their decay tails: consecutive notes on nearby
frets left three, four, five wires glowing at once — the picket fence
the chord rule was written to avoid, arriving through time instead of
through a shape.

The apply pass now decays every wire's glow state as before, but flashes
only the outermost pair of the lit span (or the single wire when only
one is above threshold). Interior wires keep decaying invisibly — the
base tier loop re-seeds their materials each frame — so the bracket
tightens naturally as the outer tails expire, and a hit inside the
current span widens nothing.

Net effect: at most two wires are ever lit, and everything currently
glowing reads as one bracket, exactly like a chord.

Signed-off-by: topkoa <topkoa@gmail.com>

* feat(highway_3d): chord flash frames the lane, not the shape

The lit lane strip spans the anchor's width (minimum ~4 frets), which
can run a fret past the chord's outermost fret. The chord flash
bracketed the shape (wire behind its lowest fret, wire at its highest),
so on those anchors the bracket sat one wire INSIDE the lit lane —
reading as misaligned rather than as a frame around what's lit.

Chord hits now light the anchor lane's edge wires: the exact wires the
lane strip itself spans, and the same pair open strings already use, so
every hit shape inside a lane produces the same bracket. The shape's
own outer pair survives only as the fallback for charts with no
anchors. Fretted and open intensities merge into one entry (they light
the same two wires now), and an all-open chord on an anchor-less chart
still degrades to no flash rather than a bad index.

Signed-off-by: topkoa <topkoa@gmail.com>

* feat(highway_3d): gem rims flash string-coloured, wire-fashion

On a confirmed hit the gem's outline now flashes in the STRING'S OWN
colour with the same intensity treatment as the fret wires — the
FRET_WIRE_HIT_INTENSITY emissive ramp, faded by the provider's alpha —
instead of the fixed spring-green mHitBright rim. Just the rims: the
lateral face fill keeps its existing green, and the sustain trail is
untouched.

Mechanics mirror the wires' pattern. mRimFlash[s] is one material per
string (created with the other per-string materials, palette-retint
aware, fog-exempt, disposed in teardown); drawNote() assigns it as the
outline on a good verdict and records the verdict alpha into a
per-frame per-string max (_rimFlashIn); the flash pass applies the
intensity ramp once per string. Shared-per-string is the same
compromise mGlow already makes — two same-string gems flashing in
different phases share the brighter alpha.

No decay tail of our own, deliberately: the material is only assigned
while the provider confirms the note, and the provider's alpha already
fades. When it goes silent the outline reverts, so idle intensity never
shows.

Signed-off-by: topkoa <topkoa@gmail.com>

* feat(highway_3d): wire flash is a lightning strike, not a lingering glow

The flash was instant-on with a 0.32 s exponential tail, and a held
sustain kept re-feeding it — wires stayed lit for the whole note. The
requested feel is a shock: light hits the frets, they jolt, it's over.

The flash is now a one-shot pulse triggered on the input's rising edge:
a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped
(1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting
into the fall (the electric shudder — the crack itself stays clean),
then hard zero. A held 'active' verdict keeps the input high
continuously, which by construction triggers nothing new: one strike
per hit, and the wires go dark while the note rings on. A re-strike
after the provider goes silent re-triggers cleanly.

Seeking backward or a long stall clears all pulse state, and a pulse
whose strike time lands ahead of the playhead after a seek is
discarded. The outer-pair bracket rule is unchanged — it now selects
across pulses instead of decay tails.

Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH
(replacing FRET_WIRE_HIT_DECAY).

Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): one wire strike per judged hit, not per wire edge

The strike trigger was a rising edge on each WIRE's input, which merged
distinct hits: two consecutive correct notes on the same fret kept that
wire's input continuously high, so the second note produced no strike at
all. The wires must respond to what the player did — one strike per
judged hit-zone event.

The trigger is now per event identity, using the same seen-map pattern
as _sparkSeen: the first frame a note gets a good verdict its key
(string|fret|time — or the chord key for a strum, which strikes once as
a unit) lands in _fwStruck and requests a strike on its wires; the
event never fires again however long its verdict stays live. Because
every producer is gated, any nonzero input in the apply pass IS a fresh
strike, so it restarts a pulse already in flight — a rapid re-hit on
the same wire re-cracks instead of being swallowed.

Seeks clear the map (replayed notes strike again); it is size-bounded
like _sparkSeen. Envelope, flicker, and the outer-pair rule unchanged.

Signed-off-by: topkoa <topkoa@gmail.com>

* Revert the lightning-strike experiment — back to the decaying glow

Reverts d003532 and e05d90e. The wire flash returns to its original behaviour: instant-on at the provider's alpha with a smooth exponential fade (FRET_WIRE_HIT_DECAY 0.32 s), held sustains keep their wires lit while the note rings, and no flicker. The outer-pair bracket, lane-framed chords, and string-coloured gem rims are untouched.

Signed-off-by: topkoa <topkoa@gmail.com>

* test: wire-tier assertions follow the named constants

The two render-order tests pinned the old literal hexes (idle 0x666688). The tiers moved to named constants with a retuned idle (FRET_WIRE_IDLE_HEX 0x4A4A60); the tests now assert the code uses the constants AND pin the constants' values, so a future retune is a deliberate two-line change here rather than a silent one.

Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): clamp provider alpha in the rim-flash path (review)

The wire-flash path clamps the note-state provider's alpha to 0..1; the rim-flash accumulation used it raw, so a provider returning >1 would over-drive emissiveIntensity. Clamped to match.

Signed-off-by: topkoa <topkoa@gmail.com>

* test: add fret inlay dots (renderOrder 3) to the hierarchy header (review)

Signed-off-by: topkoa <topkoa@gmail.com>

* test: accurate depth-flag claims, anchored depth assertions (review)

Two review findings on the render-order test file, both correct:

The header claimed ALL 3D-highway materials use depthTest:false, making
renderOrder "the only" draw-order control — but the accent halo
materials set depthTest:true. Now says "nearly all", names the
exception, and calls renderOrder the primary control. A header someone
trusts mid-debug must not overclaim.

The fret-wire depthTest/depthWrite assertions matched anywhere in
screen.js, which is full of other depthTest:false materials — the test
would keep passing if the wire material dropped the flags. Both are now
anchored to the wire material literal via FRET_WIRE_IDLE_HEX (unique to
it), as two separate anchored matches so property order inside the
literal still isn't pinned.

Signed-off-by: topkoa <topkoa@gmail.com>

---------

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-18 21:11:10 -04:00
K. O. A.andGitHub 1c077c9ab7 fix(highway_3d): stop the lane at the hit line (#994)
ship-ci / ci (push) Has been cancelled
The lane maps chart time to z exactly as notes do, over the window
[now - BEHIND, now + AHEAD]. That puts its near edge at +TS*BEHIND — BEHIND
seconds PAST the hit line, toward the player. Nothing is ever drawn there:
drawNote and the chord frames both clamp to Math.min(0, dZ(dt)), so notes stop
dead at z = 0. The overhang was therefore lane surface with nothing on it.

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

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

Closes #991

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Guard test: startGig must push the manifest after setting the override (fails on
the pre-fix source). Career suite green.
2026-07-15 10:50:55 +02:00
Byron GamatosandGitHub 1702afa379 feat(career): extract the whole setlist before the gig starts (no more waiting between songs) (#971)
ship-ci / ci (push) Waiting to run
* feat(career): extract the whole setlist before the gig starts

A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.

A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.

Best-effort by design, at every level:
  - a corrupt pak in the set does not sink the prepare (it is reported in
    `failed`; the play itself surfaces the error exactly as it does outside a
    gig — slow beats blocked)
  - a host without the library resolvers degrades to a no-op rather than 500
  - a failed request just falls through to the old lazy extraction

Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.

Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.

NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.

* fix(career): bound the prepare request; validate the setlist (PR #971 review)

Both CodeRabbit findings were right.

1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.

   `await fetch(...)` only rejects on a network ERROR. A server that accepts the
   connection and then never answers hangs indefinitely — and the gig would never
   start. That makes this optimisation the exact thing the PR promises it can
   never be: the reason you cannot play.

   The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
   because unpacking a setlist is real work — but a CEILING, not a wait). Past it
   we start the gig and let the first play extract lazily, as it always did. The
   Play button is restored in a `finally`, so a timeout cannot strand the poster
   on "Preparing set…" with Play disabled — which would have been the same bug
   wearing a different hat.

2. THE `songs` BODY WAS UNVALIDATED.

   A str is iterable: "abc" would have prepared three one-character "songs". And
   the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
   Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.

Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.

* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap

CodeRabbit again, and the first one is a real hole I put there.

1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
   with NO containment guard — so `../../x` walks straight out of the library, and
   my new endpoint handed it attacker-supplied filenames. Every filename now goes
   through _resolve_dlc_path first, the same check every other filename-bound
   handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
   a Windows drive path are all refused, and nothing outside the library is
   unpacked.

2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
   library — where the endpoint exits before extraction — so it passed whether or
   not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
   endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
   fail when the cap is removed.

   Same class of mistake as the notedetect gigBlock: a test that passes for the
   wrong reason. Worth saying out loud since it is twice in one day.

3. E702 — semicolon-joined statements in the new tests, split.

51 career tests; full suite green.
2026-07-15 00:36:20 +02:00
Byron GamatosandGitHub 917d81c2d2 fix(highway): a SUPERSEDED renderer init is not a FAILED one (#970)
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.
2026-07-15 00:36:16 +02:00
Byron GamatosandGitHub 939c98214b feat(song-info): publish the playable stem list so stems can preload (fixes the 698ms freeze) (#972)
* feat(song-info): publish the playable stem list, so stems can preload

The stems plugin could only learn its stem list from the highway's WS `ready`,
which arrives once the highway is already up. So it fetched, decoded, and then
handed every stem's PCM to its audio worklet — copying the WHOLE SONG — with the
player already on screen.

For a 4-minute 6-stem pack that is over half a GIGABYTE of memcpy, in one frame,
on the main thread. Measured on a real load: a 698 ms frame, right as the
song-credits card appeared, with the venue video visibly stopping. That is the
"the video pauses when the author appears" report.

GET /api/song/{f}?stems=1 now returns the same list — [{id, url, default}] plus
full_mix_url — so the plugin can start the whole load at `song:loading`, before
the highway (and the venue) is drawn, where a stalled frame costs nothing.
Nothing about the work changes; only WHEN.

Opt-in via the query param so the library's own metadata calls — the hot path —
pay nothing. Deliberately NOT stored in the metadata cache: that is a
fixed-column table, and widening it would mean a schema migration plus a stale
row for every song already scanned, to cache something that is a plain manifest
read on an already-unpacked pack.

The safety property: REST and the WS must publish the SAME list. If they
disagreed the plugin would preload a graph and then throw it away and rebuild —
strictly worse than not preloading. So both now resolve `default` through one
shared helper (stem_default_on, extracted from load_song), and a test rebuilds
the WS's payload from load_song and requires the REST helper to produce the
identical list, rather than pinning either against a snapshot.

Also pinned: the mixdown is lifted OUT of the stem list (spec 5.3 — `full` is
not a layer; listing it beside the instruments would play the whole song on top
of the stems) while staying reachable as full_mix_url, a single-`full` pack keeps
it as its only playable stem, and an unreadable pack yields an empty list rather
than failing the request. Full suite 2608 passed.

Consumed by feedBack-plugin-stems (preloadSong).

* fix(song-info): call load_song for the stem payload — do not reimplement it

CodeRabbit caught a real bug, and it would have hit most real libraries.

load_song() falls back to the DEPRECATED `original_audio:` key when a pack has no
reserved `full` stem — which is every pack written before feedpak 1.15.0. My
payload rebuilt the full-mix rule from extract_meta and returned None for those:
REST would say "no full mix" while the WS said there was one.

Worse than a wrong field: the plugin would preload a graph WITHOUT the pristine
mix and — because the stem signature still matched — never rebuild. Unity
playback would silently downgrade to the lossy stem recombination.

That is exactly the drift this PR claims to prevent, and my test had a hole: I
only covered packs that carry a `full` stem.

So stop reimplementing. The payload now calls load_song, whose LoadedSloppak
already carries the partitioned stems and the resolved full mix, and builds the
URLs exactly as ws_highway does. Drift is now impossible by construction rather
than by agreement. extract_meta is reverted to its original shape (it never
needed to change), and the shared stem_default_on helper stays as the one place
`default: off` is resolved.

Tests rewritten to compare against load_song — the WS's own function — for a
reserved-`full` pack, a LEGACY original_audio pack (the case that was broken), and
a single-`full` pack. Also documents the `?stems=1` contract in CHANGELOG.md.
Full suite green.
2026-07-15 00:36:13 +02:00
Byron GamatosandGitHub 4e0e3c5417 fix(venue/highway): flyover replay on arrangement switch, venue on Virtuoso, and the paused throttle starving the venue (#968)
* 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.
2026-07-14 22:11:52 +02:00
Byron GamatosandGitHub 8ef97708ef perf(folder_library): render only the songs on screen — 1.3M DOM nodes -> ~30 (#965) (#967)
* perf(folder_library): render only the songs on screen (#965)

A song list rendered EVERY song it held. On a flat 50,944-song library that is
one <div> with 50,938 children and ~1,300,000 DOM nodes — ~4.2 GB of renderer
RSS, for a screen the user may not even be looking at (it was built while the
visible screen was v3-home).

It is not just this plugin's problem. A million-node document poisons unrelated
code: any `document.querySelector` that MISSES has to walk the whole tree before
returning null. That is exactly how song_preview's per-frame menu check ended up
consuming ~50% of the renderer and dropping the app to 2.7 fps
(feedBack-plugin-song-preview#7 fixes the per-frame walk; this fixes the tree it
was walking).

So render only what is on screen. Rows are uniform height (grid cards uniform
size), so the window is pure arithmetic — no per-row observers. Off-window songs
are represented by padding ON THE LIST rather than spacer elements: a spacer div
would become a grid ITEM in grid view and shift the columns, whereas padding
behaves identically in both layouts. Lists at or below VIRTUAL_MIN (200) render
in full exactly as before, so normal folders are untouched.

Two ordering fixes this forced, both real bugs waiting to happen:
  - Both expand handlers populated the list BEFORE showing it. A windowed list
    measures a real row and the scroller viewport, and both are zero under
    display:none. Show first, then populate.
  - _render() now tears down the previous render's scroll listeners. Without it
    they survive against detached nodes and leak on every re-render.

Verified in real Chromium over CDP with 50,000 rows — the DOM glue, not just the
maths:

    at top          rendered= 25 rows   scrollHeight=2,200,000px   [0..24]
    scroll   500k   rendered= 31 rows   scrollHeight=2,200,000px   [11357..11387]
    scroll 1,100k   rendered= 31 rows   scrollHeight=2,200,000px   [24994..25024]
    scroll to end   rendered= 25 rows   scrollHeight=2,200,000px   [49975..49999]

25-31 rows in the DOM instead of 50,000; scroll height exact and constant (the
scrollbar stays honest); the last row lands on song 49,999.

Tests: _visibleWindow is pure and exposed via __test — top/middle/bottom/past-
the-end windows, the grid row-packing case, the padding-plus-rendered-equals-
total invariant that keeps the list from changing height as you scroll, and the
degenerate zero-height case (a list still display:none) falling back to
render-everything rather than to an empty list. eslint clean; full JS suite
1186/1186.

* fix(folder_library): re-window on resize and on show/hide (PR #967 review)

CodeRabbit caught two real bugs in the first pass. Both are mine.

1. GRID RESIZE. perRow and rows were captured once when the list was filled, but
   paint() also runs on resize — and resizing changes the grid's column count.
   The window maths then sliced against the OLD column count: wrong songs on
   screen, and padding sized for a row count the layout no longer had (so the
   scrollbar lied). metrics() now recomputes perRow/itemH/rows together on every
   paint, so the geometry can never disagree with itself.

2. STALE WINDOWS ON SHOW/HIDE. paint() only ran on scroll and resize. Expanding
   or collapsing any section moves every list below it, and a windowed list's
   contents are a function of its POSITION — so those lists kept the window from
   their old position and showed blank padding where songs should be until the
   user happened to scroll. Both toggles now call _repaintVirtualLists().
   Re-opening an already-populated section had the same flaw.

   Collapsed lists also kept doing layout work on every scroll tick. paint() now
   bails early when the list is display:none or detached, and forgets its last
   window so re-showing repaints from scratch instead of short-circuiting on a
   stale memo.

Tests: grid re-window on a column-count change, the padding+rendered=rows
invariant at two different perRow values, and a test that PINS THE FAILURE MODE —
a mismatched perRow/rows pair must not silently look correct. 12/12.
Re-validated the DOM glue in real Chromium with 50k rows (25-31 rows rendered,
scroll height exact). eslint clean; JS 1189/1189; pytest 2597 passed.

CHANGELOG entry added (also flagged).
2026-07-14 21:37:22 +02:00
157 changed files with 37869 additions and 16255 deletions
+7
View File
@@ -63,11 +63,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# npm ci runs third-party postinstall scripts; don't leave the token in
# git config for them (this job never pushes).
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Rebuild Tailwind CSS
run: bash scripts/build-tailwind.sh
+90
View File
@@ -0,0 +1,90 @@
name: Content packs
# Build & publish opt-in career venue packs as per-pack, versioned, immutable
# releases (convention: tag `venue-<id>-v<N>`, asset `<id>-pack-v<N>.zip`),
# then open a PR bumping venues.json url/sha256/bytes. This is automation so
# publishing packs is never a person's manual job (SLIM-NIGHTLY item 1b).
#
# Immutable tags → publishing is a deliberate, versioned act, so this runs on
# manual dispatch (not push): a media change means a new version, a human call.
on:
workflow_dispatch:
inputs:
venues:
description: "Space-separated venue ids to (re)publish, e.g. 'club arena'"
required: true
default: "club"
version:
description: "Pack version N (tag venue-<id>-vN). Bump for new media."
required: true
default: "1"
concurrency:
group: content-packs
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Media lives in-tree today; add `lfs: true` once SLIM-NIGHTLY item 4
# moves venue-packs/** to Git LFS.
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build & publish packs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Never interpolate dispatch inputs straight into the shell — a crafted
# value would execute on the runner with this job's write token. Pass
# via env, validate the formats, and use a Bash argument array.
VENUES: ${{ github.event.inputs.venues }}
VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[1-9][0-9]*$ ]] || { echo "::error::version must be a positive integer"; exit 1; }
[[ "$VENUES" =~ ^[a-z0-9][a-z0-9-]*(\ [a-z0-9][a-z0-9-]*)*$ ]] || { echo "::error::venues must be space-separated venue ids"; exit 1; }
read -r -a venues <<< "$VENUES"
dirs=()
for v in "${venues[@]}"; do
dirs+=("plugins/career/venue-packs/$v")
done
python tools/content_packs.py "${dirs[@]}" \
--version "$VERSION" \
--publish \
--manifest /tmp/packs-manifest.json
cat /tmp/packs-manifest.json
- name: Apply url/sha256/bytes to venues.json
run: |
python - <<'PY'
import json, pathlib
manifest = json.load(open("/tmp/packs-manifest.json"))
vpath = pathlib.Path("plugins/career/venues.json")
data = json.loads(vpath.read_text())
for v in data["venues"]:
m = manifest.get(v["id"])
if m and v.get("pack"):
v["pack"].update(url=m["url"], sha256=m["sha256"], bytes=m["bytes"])
vpath.write_text(json.dumps(data, indent=4) + "\n")
PY
- name: Open manifest-bump PR
uses: peter-evans/create-pull-request@v6
with:
commit-message: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
title: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
body: |
Automated by the content-packs workflow after publishing
`${{ github.event.inputs.venues }}` v${{ github.event.inputs.version }}
to their `venue-<id>-v${{ github.event.inputs.version }}` releases.
Bumps `venues.json` pack url/sha256/bytes to match the uploaded zips.
branch: content-packs/manifest-bump
delete-branch: true
+664 -509
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -400,6 +400,14 @@ highway.setNoteStateProvider((note, chartTime) => {
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
#### 4. Chart-transform provider — remap the chart before rendering AND scoring (feedBack#952)
The core-owned `chart-transform` provider coordinator applies synchronous chart substitutions after difficulty filtering. Register and select providers through the capability domain; it owns persistence, refresh, splitscreen propagation, failure attribution, and diagnostics.
Provider inputs and staged outputs are isolated copies. Async returns or provider errors fail back to the original chart and expose only a fixed public failure reason. `getSongInfo()` remains the original chart contract; transform-aware consumers use the renderer bundle or `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
See [docs/capability-recipes.md](docs/capability-recipes.md#chart-transform-provider) for the manifest and registration example.
### Audio mixer fader registration (feedBack#87)
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
@@ -682,7 +690,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes |
| `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found |
| `tone_changes` | `{ type: 'tone_changes', base, base_rig?, data: [{ t, name, rig? }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found. Note the time key is **`t`**, not `time` (both the sloppak path and the legacy XML path emit `t`). `base_rig` and each entry's `rig` are the pack's **rig bindings** — ids into [`rigs.json`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#79-rigsjson) (feedpak §6.9/§7.9), carried through verbatim and **not** resolved by core: selecting a realization and applying the `intent.gm` floor belong to whatever voices the part. Both are **omitted entirely** when the chart binds no rig, so consumers predating the rig model see the payload they always did. |
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
+128
View File
@@ -0,0 +1,128 @@
{
"id": "vocals",
"name": "Vocals",
"icon": "vocals",
"order": 5,
"levels": [
{
"level": 1,
"required": 2,
"challenges": [
{
"id": "vocals.l1.first-phrase",
"title": "First Phrase",
"description": "Finish any vocal song with pitch detection on.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 1 }
},
{
"id": "vocals.l1.clean-run",
"title": "Clean Run",
"description": "Score 80%+ accuracy on a vocal song.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.8, "target": 1 }
},
{
"id": "vocals.l1.daily-grind",
"title": "Daily Grind",
"description": "Complete 3 daily quests.",
"goal": { "type": "quest_completed", "period": "daily", "target": 3 }
}
]
},
{
"level": 2,
"required": 2,
"challenges": [
{
"id": "vocals.l2.five-songs",
"title": "Warming Up",
"description": "Finish 5 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 5 }
},
{
"id": "vocals.l2.sharpshooter",
"title": "On Pitch",
"description": "Score 90%+ accuracy on a vocal song.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.9, "target": 1 }
},
{
"id": "vocals.l2.arcade-debut",
"title": "Arcade Debut",
"description": "Play 3 FeedBarcade rounds.",
"goal": { "type": "minigame_run", "target": 3 }
}
]
},
{
"level": 3,
"required": 2,
"challenges": [
{
"id": "vocals.l3.repertoire",
"title": "Repertoire",
"description": "Finish 10 different vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "distinct": true, "target": 10 }
},
{
"id": "vocals.l3.consistent",
"title": "Consistent",
"description": "Score 85%+ accuracy on 5 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.85, "target": 5 }
},
{
"id": "vocals.l3.weekly-warrior",
"title": "Weekly Warrior",
"description": "Complete 2 weekly quests.",
"goal": { "type": "quest_completed", "period": "weekly", "target": 2 }
}
]
},
{
"level": 4,
"required": 2,
"challenges": [
{
"id": "vocals.l4.streak-week",
"title": "Seven-Day Streak",
"description": "Reach a 7-day play streak.",
"goal": { "type": "streak_reached", "days": 7 }
},
{
"id": "vocals.l4.precision",
"title": "Pitch Perfect",
"description": "Score 95%+ accuracy on 3 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "target": 3 }
},
{
"id": "vocals.l4.marathon",
"title": "Marathon",
"description": "Finish 25 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 25 }
}
]
},
{
"level": 5,
"required": 2,
"challenges": [
{
"id": "vocals.l5.collector",
"title": "Collector",
"description": "Earn 5,000 lifetime Decibels.",
"goal": { "type": "db_earned", "amount": 5000 }
},
{
"id": "vocals.l5.virtuoso",
"title": "Virtuoso",
"description": "Score 95%+ accuracy on 10 different vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "distinct": true, "target": 10 }
},
{
"id": "vocals.l5.dedicated",
"title": "Dedicated",
"description": "Finish 50 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 50 }
}
]
}
]
}
+12 -3
View File
@@ -153,6 +153,14 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
Diagnostics live under `feedBack.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
## Chart-Transform Domain
The chart-transform slice (#952) is a core-owned provider coordinator implemented by [static/capabilities/chart-transform.js](../static/capabilities/chart-transform.js). Its commands register, select, clear, and refresh providers; `chart.transform` is the provider operation. Selection persists by provider id and applies to the primary highway and announced splitscreen instances.
The synchronous `highway.setChartTransform` data-plane hook runs at chart ready, mastery changes, and refresh—not per frame. Transforms receive isolated chart data after difficulty filtering and may replace notes, chords, anchors, hand shapes, chord templates, string count, tuning, capo, and cent offset. Outputs are isolated and timeline arrays are time-sorted before the built-in renderer, renderer bundle, or public getters read them. Async returns and other provider failures clear the stage and retain the original chart.
`getSongInfo()` retains original metadata; effective values are exposed by the renderer bundle and dedicated highway getters. Diagnostics under `feedBack.chart_transform.diagnostics.v1` contain provider selection/install state and a fixed public failure reason, never chart data, song identity, or raw exceptions. The domain has no compatibility shim because no earlier chart-substitution surface exists.
## MIDI-Input Domain
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
@@ -192,7 +200,7 @@ Core domains include review metadata in diagnostics:
- `active`: wired to current FeedBack behavior and expected to work as an integration point.
- `diagnostic`: support/inspection-only runtime surfaces.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane, and the chart-transform slice (#952) promotes `chart-transform` as the pre-render/pre-scoring chart substitution coordinator. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`; legacy plugin fields continue to load through their existing app paths. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
@@ -248,7 +256,7 @@ UI placement and settings contributions are real FeedBack surfaces, but they are
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.feedBack` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade. The `chart-transform` domain follows this doctrine: its substitution runs through the synchronous `highway.setChartTransform` hook (staged once per chart change), while the capability surface owns only registration, selection, and diagnostics.
## First-Party Management Plugins
@@ -295,8 +303,9 @@ From the `feedBack/` directory:
```bash
node --check static/app.js
node --check static/capabilities.js
node --check static/capabilities/chart-transform.js
node --check static/diagnostics.js
node --check plugins/capability_inspector/screen.js
node --test tests/js/*.test.js
pytest tests/test_plugin_runtime_idempotence.py tests/test_plugins.py tests/test_diagnostics_bundle.py -q
```
```
+51
View File
@@ -499,6 +499,57 @@ window.feedBack.on('progression:quest-completed', (e) => {
});
```
## Chart-Transform Provider
Plugins that transpose, simplify, annotate, or otherwise rewrite chart data register as `chart-transform` providers (#952). The effective chart reaches the built-in highway, custom renderers, and highway getters on primary and splitscreen instances.
```json
{
"id": "my_transform",
"name": "My Transform",
"standards": ["capability-pipelines.v1"],
"capabilities": {
"chart-transform": {
"roles": ["provider"],
"operations": ["chart.transform"],
"mode": "active",
"compatibility": "none",
"ownership": "multi-provider",
"safety": "safe",
"version": 1
}
}
}
```
```js
const api = window.feedBack.capabilities;
await api.dispatch({
capability: 'chart-transform',
command: 'register-provider',
source: 'my_transform',
payload: {
providerId: 'my_transform',
label: 'My Transform',
transform(input) {
const notes = rewriteNotes(input.notes);
const allNotes = input.allNotes === input.notes ? notes : rewriteNotes(input.allNotes);
return { notes, allNotes };
},
},
});
await api.dispatch({ capability: 'chart-transform', command: 'select-provider',
source: 'my_transform', payload: { providerId: 'my_transform' } });
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: 'my_transform' });
```
`transform(input)` receives filtered `notes`, `chords`, `anchors`, and `handShapes`, plus full-difficulty `allNotes`/`allChords`, `chordTemplates`, `stringCount`, and `songInfo`. It may synchronously return any subset of those arrays plus `tuning`, `capo`, or `centOffset`; null leaves the chart unchanged. The host isolates provider inputs and outputs, time-sorts accepted timelines, and falls back to the original chart on failure.
Transforms run at chart ready, mastery recompute, and explicit `refresh`, never per frame. Selection persists by provider id. `getSongInfo()` retains original metadata; effective metadata is available through the renderer bundle and `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
## Future Expansion Domains
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
+4
View File
@@ -60,6 +60,10 @@ The progression slice (spec 010) promotes `progression` as an active exclusive-o
Deferred follow-up slices: a `contributor` role so plugins ship their own challenge/quest content (drums challenges from a drums-scoring plugin, quest-pool entries from minigame plugins), and drums scoring wiring so `song_completed {instrument: "drums"}` goals become satisfiable.
## Chart-Transform Control Plane Slice
The chart-transform slice (#952) is an active provider-coordinator domain. It owns provider lifecycle, persisted selection, refresh, failure attribution, and redaction-safe diagnostics. Its synchronous highway hook applies isolated provider output after difficulty filtering to built-in, custom-renderer, and getter consumers across primary and splitscreen highways. No compatibility shim is needed; per-panel independent selection remains a follow-up.
## Recommended Next Slices
The plugin inventory suggests this migration order after the audio graph/session and playback slices:
+1 -1
View File
@@ -20,7 +20,7 @@ Core domains also have a review scope. **Active contract** domains are wired to
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
| chart-transform | provider-coordinator | safe | inspect, list-providers, register-provider, unregister-provider, select-provider, clear-provider, refresh | chart.transform | Synchronous chart substitution after difficulty filtering (#952). Provider data is isolated, timelines are sorted, and failures retain the original chart with a fixed public reason. Diagnostics contain provider and selection state, never chart data, song identity, or raw exceptions. |
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
+17
View File
@@ -67,6 +67,23 @@ module.exports = [
'import-x/no-cycle': 'error',
},
},
// highway_3d plugin — screen.js uses ES-module syntax (scriptType:module)
// so it needs module sourceType to parse. Add no-use-before-define here
// (variables: true, functions: false) to catch const/let TDZ violations
// inside factory functions across the whole plugin tree.
// Proven to flag the broken-tip regression (fix/h3d-viz-init-fallback):
// broken: const sY used at createScoreFx DI before its declaration → ERROR
// fixed: sY hoisted above createScoreFx call → clean (0 errors)
{
files: [
'plugins/highway_3d/screen.js',
'plugins/highway_3d/src/**/*.js',
],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
rules: {
'no-use-before-define': ['error', { variables: true, functions: false }],
},
},
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
// registered files don't warn below it.
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
+1 -1
View File
@@ -2080,7 +2080,7 @@ def convert_file(
safe_name = track.name.strip().replace(" ", "_").replace("/", "_")
filename = f"{safe_name}_{arr_name or 'arr'}.xml"
filepath = out / filename
filepath.write_text(xml_str)
filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath))
return output_files
+2 -2
View File
@@ -1680,7 +1680,7 @@ def convert_file(
filepath = safe_join(out, filename)
if filepath is None:
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str)
filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath))
continue
@@ -2108,7 +2108,7 @@ def convert_file(
filepath = safe_join(out, filename)
if filepath is None:
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str)
filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath))
# Keys/piano tracks additionally get a standard-notation sidecar
+76 -6
View File
@@ -72,15 +72,59 @@ def _parse_gpif(data: bytes):
return ET.fromstring(data)
def _asset_path_from_registry(root, asset_id: str) -> str | None:
"""The ZIP path an ``<Asset id=...>`` declares, or None.
GPIF shape::
<Assets>
<Asset id="0">
<EmbeddedFilePath>Content/Assets/&lt;hash&gt;.mp3</EmbeddedFilePath>
Separators are normalised (a writer may emit backslashes) and the
result is returned as-is for the caller to verify against the
archive this function never decides that a path exists.
"""
if root is None or not asset_id:
return None
try:
for asset in root.iter('Asset'):
if (asset.get('id') or '').strip() != asset_id:
continue
node = asset.find('EmbeddedFilePath')
path = (node.text or '').strip() if node is not None else ''
if not path:
return None
return path.replace('\\', '/').lstrip('./')
except Exception:
# A malformed registry is not fatal — the caller has two more
# resolution steps behind this one.
return None
return None
def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
"""Resolve the embedded backing-track audio asset inside a .gp ZIP.
Matches ``BackingTrack/AssetId`` against the audio files under
``Content/Assets/`` (OGG, MP3, M4A, ) and falls back to the first
audio asset when the declared id is missing or unmatched. Returns
``(asset_stem, audio_zip_path)``, or ``('', None)`` when the archive
has no audio asset. Shared by ``extract_sync`` and ``extract_audio``
so the matching logic can't drift between them.
``BackingTrack/AssetId`` is a key into the GPIF's ``<Assets>``
registry ``<Asset id="0"><EmbeddedFilePath>`` names the exact path
inside the ZIP NOT a filename stem. Resolution order:
1. the registry entry for the declared id (authoritative);
2. a filename-stem match (files whose stem IS the id);
3. the archive's first audio asset.
Step 2 was previously the only lookup, which mattered because GP8
names embedded files by hash while ids are small integers, so the
stem match essentially never hit: every such file logged a warning
and fell through to step 3. That was silently correct only because a
file almost always carries exactly ONE audio asset with two, a
backing track declaring id 1 resolved to asset 0, i.e. the wrong
recording.
Returns ``(asset_stem, audio_zip_path)``, or ``('', None)`` when the
archive has no audio asset. Shared by ``extract_sync`` and
``extract_audio`` so the matching logic can't drift between them.
"""
audio_files = [
n for n in zf.namelist()
@@ -115,6 +159,32 @@ def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
declared = (aid.text or '').strip() if aid is not None else ''
if declared:
# 1. The <Assets> registry is authoritative: it maps the id to the
# embedded path directly. Membership in the archive is verified
# rather than trusted — the path comes out of the file, and a
# stale/edited entry must fall through, not resolve to nothing.
registry_path = _asset_path_from_registry(root, declared)
if registry_path:
# Matched on STEM, not the whole path, so a format variant of the
# same recording can win (see _prefer_ogg) — but constrained to the
# directory the registry actually named. Without that constraint an
# unrelated file that merely shares the stem could stand in for the
# declared asset, which is the failure the registry lookup exists
# to prevent.
declared_path = Path(registry_path)
same_stem = [
n for n in audio_files
if Path(n).stem == declared_path.stem
and Path(n).parent == declared_path.parent
]
if same_stem:
return declared_path.stem, _prefer_ogg(same_stem)
_log.warning(
'gp8_audio_sync: AssetId %r maps to %r, which is not an audio '
'asset in the archive; falling back',
declared, registry_path,
)
# 2. Legacy shape: files whose stem IS the declared id.
matched = [n for n in audio_files if Path(n).stem == declared]
if matched:
return declared, _prefer_ogg(matched)
+105 -26
View File
@@ -17,7 +17,12 @@ import threading
from typing import ClassVar
import appstate
from metadata_db import MetadataDB, _tuning_group_key_sql
from metadata_db import (
MetadataDB, _effective_tuning_cols_sql, _perspective_is_inferred_sql,
_tuning_group_key_sql,
)
import tunings as tunings_mod
from tunings import DEFAULT_PERSPECTIVE, PERSPECTIVES
from routers import art as art_router
import logging
@@ -39,9 +44,6 @@ def _safe_art_redirect_url(url: str) -> str | None:
return None
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
class LocalLibraryProvider:
id = "local"
label = "My Library"
@@ -69,28 +71,43 @@ class LocalLibraryProvider:
def query_stats(self, **kwargs) -> dict:
return self._db.query_stats(**kwargs)
def tuning_names(self) -> dict:
def tuning_names(self, instrument: str = DEFAULT_PERSPECTIVE) -> dict:
# Group custom tunings on their raw offsets so distinct ones stay
# distinct (tuning_name collapses them all to "Custom Tuning"); named
# tunings keep grouping by name (stable across the rescan boundary, no
# offsets/name split). `key` is the value the client sends back as the
# filter selector — equal to the name for named tunings, the offsets
# string for customs; offsets also feed the client's custom-pill label.
#
# `instrument=bass` swaps every column for its effective bass-facing
# expression (bass arrangement's tuning, guitar fallback) — the SAME
# expressions _build_intrinsic_where filters on, so a facet entry
# always selects exactly the songs it counted.
name_sql, offsets_sql, sort_sql = _effective_tuning_cols_sql("songs", instrument)
gkey_sql = _tuning_group_key_sql("songs", instrument)
# How many of a row's songs are showing an INFERRED tuning — i.e. have
# no bass chart of their own and are falling back to the guitar-derived
# one. Reported per entry so the UI can be honest about it instead of
# presenting a borrowed tuning as a measured one. Always 0 for guitar.
inferred_sql = f"SUM({_perspective_is_inferred_sql('songs', instrument)})"
with self._db._lock:
rows = self._db.conn.execute(
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
f"SELECT {name_sql}, {gkey_sql} AS gkey, "
f"MIN({sort_sql}), COUNT(*), MIN({offsets_sql}), {inferred_sql} "
f"FROM songs WHERE title != '' AND COALESCE({name_sql}, '') != '' "
"GROUP BY gkey COLLATE NOCASE "
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
"tuning_name COLLATE NOCASE"
f"ORDER BY ABS(COALESCE(MIN({sort_sql}), 0)), "
f"COALESCE(MIN({sort_sql}), 0) ASC, "
f"{name_sql} COLLATE NOCASE"
).fetchall()
return {
"instrument": instrument,
"tunings": [
{"name": name, "key": gkey, "offsets": offs or "",
"sort_key": int(sk or 0), "count": count}
for name, gkey, sk, count, offs in rows
"sort_key": int(sk or 0), "count": count,
# Portion of `count` borrowed from the guitar chart.
"inferred_count": int(inferred or 0)}
for name, gkey, sk, count, offs, inferred in rows
],
}
@@ -330,9 +347,16 @@ class SmartCollectionProvider:
# have been hand-edited; never let a bad value reach a query.
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
def _filter_kwargs(self) -> dict:
return _library_filter_args(**{k: v for k, v in self._rules.items()
def _filter_kwargs(self, instrument: str = "", playable_from_pitch=None) -> dict:
# `instrument` is the CALLER's play perspective (rides every request),
# never part of the saved rules — a collection saved by a guitarist
# must still read in bass tunings for a bass player, and vice versa.
args = _library_filter_args(**{k: v for k, v in self._rules.items()
if k in _LIBRARY_FILTER_PARAM_KEYS})
args["instrument"] = _normalize_instrument(instrument)
# The caller's CURRENT tuning is likewise per-request, never a saved rule.
args["playable_from_pitch"] = playable_from_pitch
return args
def _sort(self, fallback: str) -> str:
# A collection may pin its own sort (e.g. "recently added"); query_page
@@ -340,28 +364,31 @@ class SmartCollectionProvider:
return self._rules.get("sort") or fallback
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
naming_mode="legacy", **_ignore):
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_page(
page=page, size=size, sort=self._sort(sort), direction=direction,
naming_mode=naming_mode, **self._filter_kwargs())
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy",
instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_artists(
letter=letter, page=page, size=size, naming_mode=naming_mode,
**self._filter_kwargs())
**self._filter_kwargs(instrument, playable_from_pitch))
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
def query_albums(self, *, page=0, size=120, naming_mode="legacy",
instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_albums(
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
page=page, size=size, naming_mode=naming_mode,
**self._filter_kwargs(instrument, playable_from_pitch))
def query_stats(self, *, sort="artist", want_sort_letters=False,
naming_mode="legacy", **_ignore):
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_stats(
sort=self._sort(sort), want_sort_letters=want_sort_letters,
naming_mode=naming_mode, **self._filter_kwargs())
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
def tuning_names(self):
return self._local.tuning_names()
def tuning_names(self, instrument: str = "guitar"):
return self._local.tuning_names(instrument=_normalize_instrument(instrument))
async def get_art(self, song_id: str):
return await self._local.get_art(song_id)
@@ -390,7 +417,10 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "") -> dict:
has_lyrics: str = "", tunings: str = "",
instrument: str = "", tuning_match: str = "",
playable_offsets: str = "", playable_instrument: str = "",
playable_string_count: str = "") -> dict:
fmt = format if format in ("archive", "sloppak", "loose") else ""
return {
"q": q,
@@ -404,9 +434,58 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
"stems_lacks": _split_csv(stems_lacks),
"has_lyrics": _parse_has_lyrics(has_lyrics),
"tunings": _split_csv(tunings),
# Which perspective the tuning facet/filter/sort speaks for (the
# caller's play role, NOT a saved rule — see _sanitize_collection_rules).
"instrument": _normalize_instrument(instrument),
# "Playable without retuning" mode: the caller's CURRENT tuning,
# resolved to the one number the comparison needs. None = exact-match
# mode (the default), so the tuning pills behave exactly as before.
"playable_from_pitch": (
_playable_from_pitch(playable_offsets, playable_instrument,
playable_string_count)
if tuning_match == "playable" else None),
}
def _playable_from_pitch(offsets_csv: str, instrument: str, string_count: str):
"""Lowest open-string MIDI pitch of the CALLER's current tuning.
The client sends its live working tuning (offsets + instrument + string
count) rather than a precomputed pitch, so the pitch tables stay in one
place (lib/tunings.py) instead of being duplicated in JS.
Returns None for anything unusable the caller then applies NO playable
filter at all. That is the neutral state, not a claim: a malformed tuning
must not silently assert that everything is playable OR that nothing is.
"""
try:
offsets = [int(x) for x in _split_csv(offsets_csv)]
except (TypeError, ValueError):
return None
if not offsets:
return None
inst = "bass" if instrument == "bass" else "guitar"
try:
sc = int(string_count)
except (TypeError, ValueError):
sc = len(offsets)
key = tunings_mod.instrument_key(inst, sc)
if key not in tunings_mod.STANDARD_OPEN_MIDIS or len(offsets) != sc:
return None
midis = tunings_mod.tuning_midis_from_offsets(key, offsets)
return min(midis) if midis else None
def _normalize_instrument(raw: str) -> str:
"""Resolve a tuning PERSPECTIVE id (guitar-lead | guitar-rhythm | bass).
Tolerates the legacy two-valued vocabulary ("guitar" -> guitar-lead) and
falls back to the default for anything unknown an unrecognised value
must never silently change filter semantics."""
return raw if raw in PERSPECTIVES else (
DEFAULT_PERSPECTIVE if raw != "bass" else "bass")
def _sync_collection_provider(collection: dict) -> None:
"""Register (or replace) the provider for one collection."""
appstate.library_providers.register(
+21 -1
View File
@@ -225,13 +225,18 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
Returns (arrangements_list, shared_meta).
shared_meta contains title/artist/album/year/duration/tuning_offsets
sourced from the highest-priority arrangement (lead > combo > rhythm >
bass) picking the guitar tuning when both bass and lead are present.
bass) picking the guitar tuning when both bass and lead are present
plus `bass_tuning_offsets` from the first bass arrangement (None when the
folder has none), so the index can carry both tunings.
"""
arrangements = []
# Track which arrangement priority sourced shared_meta so a later,
# higher-priority arrangement (lead < bass in sort order) overrides.
shared_meta = {}
shared_priority = None
# First tuning seen per arrangement ROLE, kept alongside the guitar-first
# song tuning so the library can answer for the part a player plays.
role_tunings: dict[str, list[int] | None] = {"bass": None, "rhythm": None}
for xml in sorted(_iter_local_xmls(path)):
# Trust the XML root over the filename — a custom named
@@ -269,6 +274,10 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
"duration", "tuning_offsets")}
shared_priority = priority
if (arr_type in role_tunings and role_tunings[arr_type] is None
and meta.get("tuning_offsets")):
role_tunings[arr_type] = list(meta["tuning_offsets"])
arrangements.append({
"type": arr_type,
"name": arr_name,
@@ -281,6 +290,8 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
a["index"] = i
del a["priority"]
for role, offs in role_tunings.items():
shared_meta[f"{role}_tuning_offsets"] = offs
return arrangements, shared_meta
@@ -412,6 +423,14 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
xml_meta.get("duration", 0))
tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
xml_meta.get("tuning_offsets"))
# Per-role tunings: XML-derived only. A manifest `tuning_offsets` overrides
# the SONG tuning (above) but says nothing about WHICH chart it describes,
# so it must never be mistaken for a specific part's tuning.
role_tunings = {}
for role in ("bass", "rhythm"):
offs = xml_meta.get(f"{role}_tuning_offsets")
role_tunings[f"{role}_tuning_offsets"] = (
offs if isinstance(offs, list) and offs else None)
manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
if manifest_arr is not None:
@@ -427,6 +446,7 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
"year": year,
"duration": duration,
"tuning_offsets": tuning_offsets,
**role_tunings, # None = no arrangement in that role
"arrangements": arrangements,
"audio_path": str(audio) if audio else None,
"art_path": str(art) if art else None,
+344 -36
View File
@@ -25,6 +25,8 @@ import time
from pathlib import Path
from song import compute_smart_names
from tunings import DEFAULT_PERSPECTIVE, ROLE_PERSPECTIVES
from tunings import perspective as _perspective
log = logging.getLogger("feedBack.server")
@@ -34,17 +36,113 @@ log = logging.getLogger("feedBack.server")
# raw offsets so distinct customs stay distinct, while named tunings keep
# grouping by name (stable across the offsets-column migration). Used by both
# the tuning-names listing and the filter WHERE so the contract matches.
def _tuning_group_key_sql(alias: str) -> str:
"""The tuning grouping key (name for named tunings, raw offsets for
customs) against an explicit table alias the grouped filter law (§7.1)
evaluates chart-intrinsic predicates inside a member subquery, where bare
column names would resolve against the wrong scope."""
return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")
#
# A non-default PERSPECTIVE (guitar-rhythm / bass) swaps every tuning column
# for its EFFECTIVE expression: that role's indexed tuning when the song has
# such an arrangement, falling back to the guitar-derived song tuning
# otherwise — so a song with no rhythm/bass chart (or a row that predates the
# columns, NULL there) still groups/filters/sorts instead of disappearing.
# guitar-lead reads the original unprefixed columns, so it is byte-identical
# to the historical behaviour.
def _effective_tuning_cols_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> tuple[str, str, str]:
"""(name_sql, offsets_sql, sort_key_sql) for the given perspective."""
persp = _perspective(perspective)
if not persp.column_prefix:
return (f"{alias}.tuning_name", f"{alias}.tuning_offsets", f"{alias}.tuning_sort_key")
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
return (
f"COALESCE(NULLIF({alias}.{persp.column('name')}, ''), {alias}.tuning_name)",
f"CASE WHEN {has_own} THEN {alias}.{persp.column('offsets')} ELSE {alias}.tuning_offsets END",
f"CASE WHEN {has_own} THEN {alias}.{persp.column('sort_key')} ELSE {alias}.tuning_sort_key END",
)
def _effective_low_pitch_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
"""Lowest open-string MIDI pitch under this perspective, with the same
fallback as the tuning columns the "playable without retuning"
comparison reads it (see tunings.chart_is_playable_in)."""
persp = _perspective(perspective)
if not persp.column_prefix:
return f"{alias}.tuning_low_pitch"
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
return (f"CASE WHEN {has_own} THEN {alias}.{persp.column('low_pitch')} "
f"ELSE {alias}.tuning_low_pitch END")
def _perspective_is_inferred_sql(alias: str, perspective: str) -> str:
"""1 when this row is BORROWING the guitar-derived song tuning because it
has no chart in the perspective's role. Always 0 for guitar-lead, which is
never a fallback."""
persp = _perspective(perspective)
if not persp.column_prefix:
return "0"
return f"(CASE WHEN COALESCE({alias}.{persp.column('name')}, '') = '' THEN 1 ELSE 0 END)"
# ── The custom-tuning group key ──────────────────────────────────────────────
#
# Named tunings group by NAME, which is already serialization-agnostic. Custom
# tunings group on a raw offsets STRING, which is not: the same physical bass
# tuning stored as "-2 0 0 0" and "-2 0 0 0 0 0" would fragment into two facet
# rows with split counts.
#
# For BASS we therefore group customs on `bass_tuning_key` — the tuning's
# absolute open-string PITCHES, computed once at scan time
# (tunings.bass_tuning_key) after the padded tail is truncated away. Pitch is
# the identity that matters musically and it is serialization-independent, so
# one physical tuning is one entry however it was authored. Guitar keeps the
# offsets string (unchanged; six-element guitar arrays are not padded).
#
# The key is built HERE, once, and read by the facet listing, the filter WHERE
# and the grouped member-match alike — a facet row that selected a different
# set than it counted is exactly the bug this shared expression prevents.
def _tuning_group_key_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
"""The tuning grouping key (name for named tunings, canonical pitches or
raw offsets for customs) against an explicit table alias the grouped
filter law (§7.1) evaluates chart-intrinsic predicates inside a member
subquery, where bare column names would resolve against the wrong scope."""
persp = _perspective(perspective)
name_sql, offsets_sql, _ = _effective_tuning_cols_sql(alias, perspective)
if persp.column_prefix:
# Fall back to the offsets string when the canonical key is absent
# (a fallback row borrowing the guitar tuning, or a row scanned before
# the key column existed) so a custom never groups under an empty key.
offsets_sql = (f"COALESCE(NULLIF({alias}.{persp.column('key')}, ''), "
f"{offsets_sql})")
return (f"CASE WHEN {name_sql} = 'Custom Tuning' AND COALESCE({offsets_sql}, '') != '' "
f"THEN {offsets_sql} ELSE {name_sql} END")
def _put_perspective_value(meta: dict, col: str):
"""Value to store for one per-perspective column on a freshly-scanned row."""
if col.endswith("_low_pitch"):
val = meta.get(col)
return int(val) if isinstance(val, int) else None
if col.endswith("_sort_key"):
return int(meta.get(col, 0) or 0)
return meta.get(col, "") or ""
# ── SQLite metadata cache ─────────────────────────────────────────────────────
def _arrangements_all_bass(raw) -> bool:
"""True when EVERY arrangement on a chart is a bass part (raw ``arrangements``
JSON, as stored). Mirrors the library grid's card rule: such a chart's tuning
must be scored against bass base pitches, or a 4-string bass tuning read as
guitar can false-match a guitarist. A chart with no arrangements is not bass.
"""
try:
arrs = json.loads(raw) if raw else []
except (ValueError, TypeError):
return False
if not isinstance(arrs, list) or not arrs:
return False
return all(
isinstance(a, dict) and re.search(r"\bbass\b", str(a.get("name") or ""), re.I)
for a in arrs
)
def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
"""Fill in missing ``smart_name`` fields and sort arrangements by smart order.
@@ -381,7 +479,18 @@ class MetadataDB:
tuning_offsets TEXT DEFAULT '',
genre TEXT DEFAULT '',
track_number INTEGER,
disc INTEGER
disc INTEGER,
bass_tuning_name TEXT,
bass_tuning_sort_key INTEGER,
bass_tuning_offsets TEXT,
bass_tuning_key TEXT,
bass_tuning_low_pitch INTEGER,
rhythm_tuning_name TEXT,
rhythm_tuning_sort_key INTEGER,
rhythm_tuning_offsets TEXT,
rhythm_tuning_key TEXT,
rhythm_tuning_low_pitch INTEGER,
tuning_low_pitch INTEGER
)
""")
# Idempotent migrations for installs that predate each column.
@@ -408,6 +517,32 @@ class MetadataDB:
# falls back to title order. Cache; repopulated on rescan.
"ALTER TABLE songs ADD COLUMN track_number INTEGER",
"ALTER TABLE songs ADD COLUMN disc INTEGER",
# Bass-arrangement tuning (the KwasimodoZAZA report): the song-level
# tuning columns above are guitar-first, so the library filter lied
# to bass players when the bass chart is tuned differently. Caches;
# repopulated on rescan. NULL (no literal default) is deliberate —
# it marks a pre-migration row the scanner must re-extract, while
# '' means "extracted, song has no bass arrangement" (see scan.py).
"ALTER TABLE songs ADD COLUMN bass_tuning_name TEXT",
"ALTER TABLE songs ADD COLUMN bass_tuning_sort_key INTEGER",
"ALTER TABLE songs ADD COLUMN bass_tuning_offsets TEXT",
# Canonical grouping key: the bass tuning's absolute open-string
# pitches. Keyed on PITCH, not the serialization-dependent offsets
# string, so one physical tuning is one facet entry however it was
# stored. See tunings.bass_tuning_key.
"ALTER TABLE songs ADD COLUMN bass_tuning_key TEXT",
# Lowest open-string MIDI pitch per perspective — the "playable
# without retuning" comparison (tunings.chart_is_playable_in).
"ALTER TABLE songs ADD COLUMN bass_tuning_low_pitch INTEGER",
"ALTER TABLE songs ADD COLUMN tuning_low_pitch INTEGER",
# The RHYTHM chart's own tuning: lead and rhythm arrangements can
# be tuned differently, which is the same bug a bassist hit,
# inside guitar. Same NULL-vs-'' contract as the bass family.
"ALTER TABLE songs ADD COLUMN rhythm_tuning_name TEXT",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_sort_key INTEGER",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_offsets TEXT",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_key TEXT",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_low_pitch INTEGER",
):
try:
self.conn.execute(ddl)
@@ -667,6 +802,16 @@ class MetadataDB:
self.conn.execute(_ddl)
except sqlite3.OperationalError:
pass
# Manual playlist ordering (tester ask): `position` orders the
# PLAYLISTS themselves (playlist_songs.position orders songs within
# one). NULL = unpositioned — those sort alphabetically AFTER the
# manually positioned ones, and system playlists stay pinned first
# regardless (see list_playlists). Additive, idempotent — same
# pattern as `rules`/`kind` above.
try:
self.conn.execute("ALTER TABLE playlists ADD COLUMN position INTEGER")
except sqlite3.OperationalError:
pass
# Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
# analogue. Unlike playlists (which reference owned local songs by
@@ -2405,10 +2550,14 @@ class MetadataDB:
def list_playlists(self) -> list[dict]:
from urllib.parse import quote
# Order: system playlists pinned first, then manually positioned user
# playlists (position = drag order), then unpositioned ones
# alphabetically — so a manual order wins and a playlist created after
# a reorder still lands somewhere predictable (see reorder_playlists).
rows = self.conn.execute(
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
"WHERE rules IS NULL " # smart collections live in the source picker, not here
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
"ORDER BY (system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE"
).fetchall()
out = []
for r in rows:
@@ -2566,7 +2715,9 @@ class MetadataDB:
rows = self.conn.execute(
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
ps.arrangement, ps.work_key, s.arrangements,
(s.filename IS NULL) AS dead
(s.filename IS NULL) AS dead, s.tuning_offsets,
s.bass_tuning_name, s.bass_tuning_offsets,
s.rhythm_tuning_name, s.rhythm_tuning_offsets
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ? {dead_filter}
ORDER BY ps.position, ps.filename""",
@@ -2578,6 +2729,17 @@ class MetadataDB:
entry = {
"filename": r[0], "position": r[1],
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
# Offsets + the bass-only flag let the playlist tuning check score a
# row against the player's working tuning the same way the library
# grid's chips do: a NAME alone can't be scored (two "Custom Tuning"
# rows are different tunings), and coverage needs to know whether to
# measure against bass or guitar base pitches.
"tuning_offsets": r[9] or "",
"bass_tuning_name": r[10] or "",
"bass_tuning_offsets": r[11] or "",
"rhythm_tuning_name": r[12] or "",
"rhythm_tuning_offsets": r[13] or "",
"bass_only": _arrangements_all_bass(r[7]),
"art_url": f"/api/song/{quote(r[0])}/art",
}
if is_album:
@@ -2605,7 +2767,9 @@ class MetadataDB:
if work_key:
self._ensure_work_display()
row = self.conn.execute(
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
(work_key,)).fetchone()
@@ -2615,8 +2779,16 @@ class MetadataDB:
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
except Exception:
arrs = []
# An orphan-resolved slot PLAYS a different chart, so it must report
# that chart's tuning to the check — not the dead pin's.
return {"resolved_filename": row[0], "title": row[1] or row[0],
"artist": row[2] or "", "tuning_name": row[3] or "",
"tuning_offsets": row[5] or "",
"bass_tuning_name": row[6] or "",
"bass_tuning_offsets": row[7] or "",
"rhythm_tuning_name": row[8] or "",
"rhythm_tuning_offsets": row[9] or "",
"bass_only": _arrangements_all_bass(row[4]),
"arrangements": arrs,
"art_url": f"/api/song/{quote(row[0])}/art",
"resolved_from_orphan": True}
@@ -2710,6 +2882,30 @@ class MetadataDB:
self.conn.commit()
return True
def reorder_playlists(self, ordered_ids: list[int]) -> bool:
"""Persist a manual ordering of the playlists THEMSELVES: position =
index in `ordered_ids` (the songs-within sibling is reorder_playlist).
Caller (the route) validates the list is an exact permutation of the
current non-system playlist ids."""
with self._lock:
for pos, pid in enumerate(ordered_ids):
self.conn.execute(
"UPDATE playlists SET position = ?, updated_at = datetime('now') WHERE id = ?",
(pos, pid),
)
self.conn.commit()
return True
def clear_playlist_positions(self) -> bool:
"""Drop every manual playlist position → back to alphabetical
(the "Sort AZ" affordance)."""
with self._lock:
self.conn.execute(
"UPDATE playlists SET position = NULL, updated_at = datetime('now') "
"WHERE position IS NOT NULL")
self.conn.commit()
return True
def toggle_saved(self, filename: str) -> bool:
"""Add/remove a song on the Saved-for-Later playlist. Returns new state.
The presence check and the add/remove run under one lock so two
@@ -2810,16 +3006,39 @@ class MetadataDB:
def favorite_set(self) -> set[str]:
return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()}
# Every per-perspective column, in one place, so the SELECT, the INSERT and
# the scanner's "was this ever extracted?" check can never drift apart.
# NULL is meaningful on `name`/`key`/`low_pitch`: it marks a row written
# before the column existed, which the scanner re-extracts (see
# scan._has_unextracted_columns). '' / 0 means "extracted, no such chart".
_PERSPECTIVE_COLS = tuple(
p.column(suffix)
for p in ROLE_PERSPECTIVES
for suffix in ("name", "sort_key", "offsets", "key", "low_pitch")
) + ("tuning_low_pitch",)
# Columns whose NULL means "never extracted" rather than "no such chart".
#
# low_pitch is deliberately NOT a marker: a song with no chart in that role
# legitimately has NULL there (nothing to compute a pitch from), so keying
# re-extraction on it would re-scan those rows on every single pass and
# never converge. `name` and `key` carry the signal instead — they are ''
# when extracted-but-absent, NULL only when the column predates the row.
_EXTRACTION_MARKER_COLS = tuple(
p.column(suffix) for p in ROLE_PERSPECTIVES for suffix in ("name", "key")
)
def get(self, filename: str, mtime: float, size: int) -> dict | None:
cache_key = str(filename)
pcols = ", ".join(self._PERSPECTIVE_COLS)
with self._lock:
row = self.conn.execute(
"SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, "
f"{pcols} "
"FROM songs WHERE filename = ?", (cache_key,)
).fetchone()
if row and row[0] == mtime and row[1] == size and row[2]:
return {
out = {
"title": row[2], "artist": row[3], "album": row[4],
"year": row[5], "duration": row[6], "tuning": row[7],
"arrangements": json.loads(row[8]) if row[8] else [],
@@ -2831,6 +3050,15 @@ class MetadataDB:
"tuning_sort_key": int(row[14] or 0),
"tuning_offsets": row[15] or "",
}
for i, col in enumerate(self._PERSPECTIVE_COLS, start=16):
val = row[i]
if col in self._EXTRACTION_MARKER_COLS:
out[col] = val # NULL preserved — drives re-extraction
elif col.endswith("_sort_key"):
out[col] = int(val or 0)
else:
out[col] = val or ""
return out
return None
def put(self, filename: str, mtime: float, size: int, meta: dict):
@@ -2838,8 +3066,9 @@ class MetadataDB:
self.conn.execute(
"INSERT OR REPLACE INTO songs "
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc, "
+ ", ".join(self._PERSPECTIVE_COLS) + ") "
"VALUES (" + ", ".join(["?"] * (20 + len(self._PERSPECTIVE_COLS))) + ")",
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
@@ -2852,7 +3081,14 @@ class MetadataDB:
meta.get("tuning_offsets", "") or "",
meta.get("genre", "") or "",
meta.get("track_number"),
meta.get("disc")),
meta.get("disc"),
# A put() row is by definition freshly extracted, so the
# marker columns must never be written NULL — that state is
# reserved for rows predating the column, which re-extract.
# low_pitch is the exception: NULL there means "this tuning
# has no computable pitch" (unusable offsets), and the
# playable filter treats unknown as not-playable.
*[_put_perspective_value(meta, col) for col in self._PERSPECTIVE_COLS]),
)
self.conn.commit()
# A song's identity may have changed → the grouping read-model is stale.
@@ -3332,6 +3568,8 @@ class MetadataDB:
match_states: list[str] | None = None,
genre: list[str] | None = None,
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None,
include_intrinsic: bool = True) -> tuple[str, list]:
"""Shared WHERE-clause builder for query_page / query_artists /
query_stats. Returns (where_sql, params). Leading 'WHERE' is
@@ -3438,7 +3676,8 @@ class MetadataDB:
"songs", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch)
where += ifrag
params += iparams
return where, params
@@ -3450,7 +3689,9 @@ class MetadataDB:
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
naming_mode: str = "legacy") -> tuple[str, list]:
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> tuple[str, list]:
"""CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
tuning) as ' AND …' fragments against an explicit table alias. Flat
queries apply them to `songs` directly; grouped queries evaluate them
@@ -3593,10 +3834,32 @@ class MetadataDB:
placeholders = ",".join(["?"] * len(tn))
# Match the same grouping key tuning_names() returns so a single
# "Custom Tuning" pill selects exactly its offset set while named
# tunings still match by name.
where += (f" AND {_tuning_group_key_sql(alias)} "
# tunings still match by name. `instrument` swaps in the
# effective bass tuning key (guitar fallback) — the facet and
# this WHERE must use the same expression or they disagree.
where += (f" AND {_tuning_group_key_sql(alias, instrument)} "
f"COLLATE NOCASE IN ({placeholders})")
params += tn
if playable_from_pitch is not None:
# "Playable without retuning" — the mode the tester actually wants
# ("don't make me retune"), offered ALONGSIDE exact match, not
# instead of it. A chart needs no retune when its lowest required
# pitch is reachable, and every pitch above your lowest open string
# is reachable by fretting, so the comparison is:
#
# your lowest open pitch <= the chart's lowest open pitch
#
# That is why a 5-string bass (low B) covers every 4-string
# standard AND every drop-D chart untouched.
#
# CONSERVATIVE BY CONSTRUCTION: a chart whose low pitch we could
# not compute (NULL) is EXCLUDED rather than assumed playable —
# wrongly claiming playability costs a mid-practice retune, which
# is the failure this whole feature exists to prevent. See
# tunings.chart_is_playable_in for the full reasoning + limits.
low_sql = _effective_low_pitch_sql(alias, instrument)
where += f" AND {low_sql} IS NOT NULL AND {low_sql} >= ?"
params.append(int(playable_from_pitch))
return where, params
# Under group=1, chart-intrinsic filters match if ANY member of the work
@@ -3864,7 +4127,9 @@ class MetadataDB:
genre: list[str] | None = None,
after: str | None = None,
group: bool = False,
naming_mode: str = "legacy") -> tuple[list[dict], int]:
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
"""Server-side paginated search. Returns (songs, total_count).
`after` is an opaque keyset cursor (the last row of the previous page).
@@ -3893,7 +4158,9 @@ class MetadataDB:
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
match_states=match_states, genre=genre,
naming_mode=naming_mode, include_intrinsic=not group,
naming_mode=naming_mode, instrument=instrument,
playable_from_pitch=playable_from_pitch,
include_intrinsic=not group,
)
ifrag, iparams = "", []
if group:
@@ -3902,12 +4169,14 @@ class MetadataDB:
"m", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch)
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
where += mfrag
params += mparams
where += self._GROUP_REP_PREDICATE
_eff_tuning_name, _, _eff_tuning_sort = _effective_tuning_cols_sql("songs", instrument)
sort_map = {
# Artist sorts order WITHIN an artist by title (the tree view's
# artist -> album -> title feel) instead of raw filename — the
@@ -3941,11 +4210,15 @@ class MetadataDB:
# behind, and a NULL `tuning_name` in `(tuning_name = '')`
# evaluates to NULL itself (which sorts ahead of 0 in
# ASC), defeating the push-to-bottom intent.
#
# Under `instrument=bass` the effective expressions swap in
# the bass arrangement's tuning (guitar fallback) so a bass
# player's tuning sort orders by the tuning they'd play.
"tuning": (
"(COALESCE(tuning_name, '') = '') ASC, "
"ABS(COALESCE(tuning_sort_key, 0)), "
"COALESCE(tuning_sort_key, 0) ASC, "
"COALESCE(tuning_name, '') COLLATE NOCASE"
f"(COALESCE({_eff_tuning_name}, '') = '') ASC, "
f"ABS(COALESCE({_eff_tuning_sort}, 0)), "
f"COALESCE({_eff_tuning_sort}, 0) ASC, "
f"COALESCE({_eff_tuning_name}, '') COLLATE NOCASE"
),
# Year sort (feedBack#128). Empty-year rows pushed to the
# bottom for both directions; otherwise CAST so '2010' >
@@ -4038,7 +4311,9 @@ class MetadataDB:
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
"tuning_name, tuning_offsets FROM songs ")
"tuning_name, tuning_offsets, bass_tuning_name, bass_tuning_offsets, "
"rhythm_tuning_name, rhythm_tuning_offsets "
"FROM songs ")
cursor = _decode_cursor(after) if after else None
eff_sort = _effective_keyset_sort(sort, direction)
if cursor and eff_sort in _KEYSET_SORTS:
@@ -4071,8 +4346,30 @@ class MetadataDB:
"stem_ids": json.loads(r[12]) if r[12] else [],
"tuning_name": r[13] or "",
"tuning_offsets": r[14] or "",
# '' when the song has no bass arrangement (or the row predates
# '' when the song has no such chart (or the row predates the
# columns) — clients fall back to tuning_name.
"bass_tuning_name": r[15] or "",
"bass_tuning_offsets": r[16] or "",
"rhythm_tuning_name": r[17] or "",
"rhythm_tuning_offsets": r[18] or "",
"has_estd": r[0] in estd, "favorite": r[0] in favs,
})
# PROVENANCE (non-default perspectives): a row shown to a bass or
# rhythm player either carries that chart's own tuning (native) or is
# borrowing the guitar-derived song tuning (inferred). The fallback is
# deliberate — a third of a real library has no bass chart and
# excluding it would be worse — but it must never be SILENT, or we
# reproduce the original bug in a new place. The client marks inferred
# rows; it can't infer this itself without duplicating the COALESCE.
#
# guitar-lead adds NOTHING here, so the default payload is unchanged.
_persp = _perspective(instrument)
if _persp.column_prefix:
_name_key = _persp.column("name")
for s in songs:
s["tuning_perspective"] = _persp.id
s["tuning_inferred"] = not s.get(_name_key)
# Personal layer (difficulty + tags) rides along like `favorite`, so a
# card can badge it without a second request. Notes stay OUT of the list
# payload (they can be long) — fetch per-song via /user-meta. Batched to
@@ -4169,7 +4466,7 @@ class MetadataDB:
rows = self.conn.execute(
"SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
"m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
"m.tuning_name, m.tuning_offsets "
"m.tuning_name, m.tuning_offsets, m.bass_tuning_name, m.bass_tuning_offsets "
"FROM songs m JOIN work_display mw ON mw.filename = m.filename "
f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
"ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
@@ -4190,6 +4487,7 @@ class MetadataDB:
"stem_count": int(m[9] or 0),
"stem_ids": json.loads(m[10]) if m[10] else [],
"tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
"bass_tuning_name": m[13] or "", "bass_tuning_offsets": m[14] or "",
}
def query_artists(self, letter: str = "", q: str = "",
@@ -4204,7 +4502,9 @@ class MetadataDB:
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
naming_mode: str = "legacy") -> tuple[list[dict], int]:
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
"""Get artists grouped by letter with their albums and songs. Returns (artists, total_artists)."""
where, params = self._build_where(
q=q, favorites_only=favorites_only, format_filter=format_filter,
@@ -4212,6 +4512,7 @@ class MetadataDB:
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch,
)
# Canonicalize artists at display when aliases exist (P4): dedupe / group /
# letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one
@@ -4247,7 +4548,7 @@ class MetadataDB:
rows = self.conn.execute(
f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, "
f"format, stem_count, stem_ids, tuning_name "
f"format, stem_count, stem_ids, tuning_name, bass_tuning_name "
f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
song_params
).fetchall()
@@ -4280,6 +4581,7 @@ class MetadataDB:
"stem_count": int(r[10] or 0),
"stem_ids": json.loads(r[11]) if r[11] else [],
"tuning_name": r[12] or "",
"bass_tuning_name": r[13] or "",
"has_estd": r[0] in estd,
"favorite": r[0] in favs,
"user_difficulty": udm.get(r[0]),
@@ -4301,7 +4603,8 @@ class MetadataDB:
stems_has=None, stems_lacks=None,
has_lyrics=None, tunings=None, mastery=None,
match_states=None, genre=None,
naming_mode="legacy", page=0, size=120):
naming_mode="legacy", instrument=DEFAULT_PERSPECTIVE,
playable_from_pitch=None, page=0, size=120):
"""Distinct (artist, album) groups with a track count + a representative
cover song, for the album-condensed browse (paged by album). Rows with no
album name are excluded -- they can't form an album card. Same filters as
@@ -4313,7 +4616,8 @@ class MetadataDB:
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
match_states=match_states, genre=genre,
naming_mode=naming_mode,
naming_mode=naming_mode, instrument=instrument,
playable_from_pitch=playable_from_pitch,
)
awhere = where + " AND album IS NOT NULL AND album != ''"
total = self.conn.execute(
@@ -4344,7 +4648,9 @@ class MetadataDB:
sort: str = "artist",
want_sort_letters: bool = False,
group: bool = False,
naming_mode: str = "legacy") -> dict:
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> dict:
"""Aggregate stats for the letter bar. Accepts the same filter
params as query_page so the letter counts stay synchronized
with the grid when filters are active.
@@ -4371,7 +4677,8 @@ class MetadataDB:
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, match_states=match_states,
naming_mode=naming_mode,
naming_mode=naming_mode, instrument=instrument,
playable_from_pitch=playable_from_pitch,
include_intrinsic=not group,
)
if group:
@@ -4383,7 +4690,8 @@ class MetadataDB:
"m", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch)
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
where += mfrag
params += mparams
+37 -11
View File
@@ -54,15 +54,20 @@ MIDDLE_C = 60
def decode_wire_notes(arr_data: dict) -> list[dict]:
"""Decode an arrangement JSON's notes + chord notes to
``[{"t": float, "midi": int, "sus": float}, ...]`` sorted by time.
``[{"t": float, "midi": int, "sus": float, "hand": str|None}, ...]``
sorted by time.
Keys content packs absolute MIDI as ``midi = s*24 + f`` (sloppak-spec
§5.3 legacy fallback). Sustain is the ``sus`` field (``l`` accepted as a
legacy alias). Entries with malformed fields are skipped.
legacy alias). ``hand`` is the authored per-note hand assignment
(``'lh'``/``'rh'`` e.g. from a MusicXML grand-staff import via the
editor); a strict enum decode, anything else reads as ``None``
(unassigned) so junk can never steer the hand split. Entries with
malformed fields are skipped.
"""
out: list[dict] = []
def _push(t, s, f, sus):
def _push(t, s, f, sus, hand):
try:
t = float(t)
midi = int(s) * 24 + int(f)
@@ -70,11 +75,15 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
except (TypeError, ValueError):
return
if 0 <= midi <= 127:
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
out.append({
"t": t, "midi": midi, "sus": max(0.0, sus),
"hand": hand if hand in ("lh", "rh") else None,
})
for n in arr_data.get("notes") or []:
if isinstance(n, dict):
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")),
n.get("hand"))
for ch in arr_data.get("chords") or []:
if not isinstance(ch, dict):
continue
@@ -83,7 +92,7 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
if isinstance(cn, dict):
# Chord notes carry no own time — they sound at the chord's t.
_push(cn.get("t", ch_t), cn.get("s"), cn.get("f"),
cn.get("sus", cn.get("l")))
cn.get("sus", cn.get("l")), cn.get("hand"))
out.sort(key=lambda n: (n["t"], n["midi"]))
return out
@@ -103,14 +112,31 @@ def group_simultaneous(notes: list[dict]) -> list[list[dict]]:
def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
"""Assign every note to ``rh`` or ``lh``.
Per simultaneous group: a span > 12 semitones splits at the largest
internal interval gap (low side lh); otherwise the whole group goes by
mean pitch vs middle C ( 60 rh).
An AUTHORED per-note ``hand`` ('lh'/'rh' a MusicXML grand-staff import
or a hand edit in the editor) always wins: those notes go straight to
their hand and are REMOVED from the group before any heuristic math runs,
so one explicit assignment can never skew its chordmates' guesses (e.g.
an authored LH melody note above middle C must not drag the group mean
down and flip the remaining notes).
The remaining unassigned notes take the heuristic, per simultaneous
group: a span > 12 semitones splits at the largest internal interval gap
(low side lh); otherwise the whole group goes by mean pitch vs middle C
( 60 rh).
"""
hands: dict[str, list[dict]] = {"rh": [], "lh": []}
for group in group_simultaneous(notes):
for full_group in group_simultaneous(notes):
# Authored hands first — explicit notes leave the group entirely.
group = []
for n in full_group:
if n.get("hand") in ("lh", "rh"):
hands[n["hand"]].append(n)
else:
group.append(n)
if not group:
continue
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
+42 -13
View File
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
import appstate
from library_registry import (
_library_filter_args, _sanitize_collection_rules,
_library_filter_args, _normalize_instrument, _sanitize_collection_rules,
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
_unregister_collection_provider,
)
@@ -52,7 +52,8 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
"mastery", "match_states")
"mastery", "match_states", "instrument",
"playable_from_pitch")
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
@@ -235,9 +236,20 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
has_lyrics: str = "", tunings: str = "", provider: str = "local",
mastery: str = "", tags: str = "", user_difficulty: str = "",
match: str = "", genre: str = "", after: str = "", group: int = 0,
naming_mode: str = "legacy"):
naming_mode: str = "legacy", instrument: str = "",
tuning_match: str = "", playable_offsets: str = "",
playable_instrument: str = "", playable_string_count: str = ""):
"""Paginated library search through the selected library provider.
`instrument` is the tuning PERSPECTIVE ("guitar-lead" default |
"guitar-rhythm" | "bass"): which arrangement's tuning the tuning
filter/sort speaks for, with a guitar fallback when a song has no chart in
that role.
`tuning_match=playable` switches the tuning filter from exact-match to
"playable without retuning" against the caller's current tuning
(`playable_offsets` + `playable_instrument` + `playable_string_count`).
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
`next_cursor` from the previous response to fetch the next page with a
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
@@ -270,7 +282,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
tuning_match=tuning_match, playable_offsets=playable_offsets,
playable_instrument=playable_instrument,
playable_string_count=playable_string_count,
),
)
# The cursor to resume after this page (effective sort folds in dir=desc).
@@ -292,7 +307,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", mastery: str = "",
match: str = "", genre: str = "",
provider: str = "local"):
provider: str = "local", instrument: str = ""):
"""Album-condensed browse: distinct (artist, album) groups with a track count
and a representative cover song. Paged by album. Same filters as /api/library."""
size = min(size, 500)
@@ -306,7 +321,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
q=q, favorites=favorites, format=format, artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
),
)
return {"albums": albums, "total": total, "page": page, "size": size}
@@ -319,7 +334,9 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
naming_mode: str = "legacy"):
naming_mode: str = "legacy", instrument: str = "",
tuning_match: str = "", playable_offsets: str = "",
playable_instrument: str = "", playable_string_count: str = ""):
"""Get artists grouped by letter with albums and songs (for tree view)."""
size = min(size, 100)
library_provider = _get_library_provider(provider)
@@ -336,7 +353,7 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
),
)
return {"artists": artists, "total_artists": total, "page": page, "size": size}
@@ -350,7 +367,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
match: str = "",
sort: str = "artist", sort_letters: int = 0,
group: int = 0, naming_mode: str = "legacy"):
group: int = 0, naming_mode: str = "legacy",
instrument: str = "", tuning_match: str = "",
playable_offsets: str = "", playable_instrument: str = "",
playable_string_count: str = ""):
"""Aggregate stats for the UI. Accepts the same filter params as
/api/library so the letter bar mirrors the active grid filter set.
`sort` selects the column the jump rail's `sort_letters` keys on;
@@ -375,7 +395,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
tuning_match=tuning_match, playable_offsets=playable_offsets,
playable_instrument=playable_instrument,
playable_string_count=playable_string_count,
),
)
@@ -407,14 +430,20 @@ def library_genres(provider: str = "local"):
@router.get("/api/library/tuning-names")
async def list_tuning_names(provider: str = "local"):
async def list_tuning_names(provider: str = "local", instrument: str = ""):
"""Distinct tuning names present in the library, with per-tuning
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
so names appear in the same musical order the sort uses
(feedBack#22) — E Standard first, then nearest neighbors."""
(feedBack#22) — E Standard first, then nearest neighbors.
`instrument=bass` groups by each song's bass-arrangement tuning
(guitar-derived fallback for songs without a bass chart) so bass
players see the tunings they'd actually play. Providers that predate
the kwarg simply don't receive it (signature-filtered)."""
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
return await _call_library_provider_async(library_provider, "tuning_names")
return await _call_library_provider_async(
library_provider, "tuning_names", instrument=_normalize_instrument(instrument))
@router.get("/api/library/practice-suggestions")
+31
View File
@@ -70,6 +70,37 @@ def api_create_playlist(data: dict):
return appstate.meta_db.create_playlist(name, kind=kind)
@router.post("/api/playlists/reorder")
def api_reorder_playlists(data: dict):
"""Manual ordering of the playlists themselves (position = index in
`order`); the songs-within sibling is /api/playlists/{pid}/reorder.
System playlists stay pinned first and are not part of the order."""
order = data.get("order")
if not isinstance(order, list) or not all(
isinstance(i, int) and not isinstance(i, bool) for i in order):
return JSONResponse({"error": "order must be a list of playlist ids"}, status_code=400)
# Require an exact permutation of the current non-system playlist ids: a
# list with duplicates, omissions, extras, unknown ids, or a system id
# would otherwise produce duplicate positions / a partial reorder while
# still returning 200 (mirrors the songs-within validation).
current = [p["id"] for p in appstate.meta_db.list_playlists() if not p["system_key"]]
if len(order) != len(current) or sorted(order) != sorted(current):
return JSONResponse(
{"error": "order must be a permutation of your playlists' ids"},
status_code=400,
)
appstate.meta_db.reorder_playlists(order)
return api_list_playlists()
@router.post("/api/playlists/sort-alpha")
def api_sort_playlists_alpha():
"""Clear every manual playlist position → back to the alphabetical
default (system playlists were pinned first either way)."""
appstate.meta_db.clear_playlist_positions()
return api_list_playlists()
@router.get("/api/playlists/{pid}")
def api_get_playlist(pid: int):
pl = appstate.meta_db.get_playlist(pid)
+70 -5
View File
@@ -829,9 +829,61 @@ def post_song_gap_fill(filename: str, data: dict):
return {"ok": True, "written": additions, "skipped": skipped}
def _playable_stems_payload(filename: str, dlc) -> dict:
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
Why it exists: the stems plugin could only learn its stem list from the
highway's WS `ready`, which arrives once the highway is already up. So it
decoded, and then copied the whole song's PCM to its worklet, with the player
on screen half a gigabyte of memcpy in one frame, ~700 ms, freezing the
venue video. Given the list at `song:loading` it can do all of that BEFORE the
highway appears, behind the loading overlay where a stall costs nothing.
The list MUST be the same one the WS sends a moment later. If it is not, the
plugin preloads a graph and then throws it away and rebuilds strictly worse
than not preloading. So this does not reimplement the WS's construction, it
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
partitioned stems and the resolved full mix, and then builds the URLs exactly
as ws_highway does. Drift is impossible by construction rather than by
agreement which matters, because `full_mix` in particular is not simply the
`full` stem: load_song falls back to the deprecated `original_audio:` key for
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
first) silently dropped the pristine full mix for most real libraries.
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
to preload: load_song raises and we return the empty list.
"""
from urllib.parse import quote
try:
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
except Exception:
return {"stems": [], "full_mix_url": None}
q_fn = quote(filename, safe="")
def _url(rel: str) -> str:
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
return {
"stems": [
{"id": s["id"], "url": _url(s["file"]), "default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}}
for s in loaded.stems
],
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
}
@router.get("/api/song/{filename:path}")
async def get_song_info(filename: str):
"""Return song metadata, from cache or by extracting it from the song source."""
async def get_song_info(filename: str, stems: int = 0):
"""Return song metadata, from cache or by extracting it from the song source.
`?stems=1` additionally returns the playable stem list with URLs, so the
stems plugin can start fetching/decoding on `song:loading` instead of waiting
for the highway's WS `ready` (see _playable_stems_payload).
"""
import asyncio
dlc = _get_dlc_dir()
if not dlc:
@@ -854,8 +906,21 @@ async def get_song_info(filename: str):
mtime, size = appstate.stat_for_cache(song_path)
cached = appstate.meta_db.get(cache_key, mtime, size)
loop = asyncio.get_event_loop()
# The stem list is NOT stored in the metadata cache: that is a fixed-column
# table, and widening it would mean a migration plus a stale row for every
# song already scanned. It is cheap to read on demand (the pack is unpacked
# by then, so this is a plain manifest read), and only the opt-in caller pays.
async def _with_stems(meta: dict) -> dict:
if not stems:
return meta
extra = await loop.run_in_executor(
None, _playable_stems_payload, filename, dlc)
return {**meta, **extra}
if cached:
return cached
return await _with_stems(cached)
# Extract in thread pool
def _extract():
@@ -863,5 +928,5 @@ async def get_song_info(filename: str):
appstate.meta_db.put(cache_key, mtime, size, meta)
return meta
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
return meta
meta = await loop.run_in_executor(None, _extract)
return await _with_stems(meta)
+69 -19
View File
@@ -26,6 +26,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from song import (
anchor_to_wire,
arrangement_is_bass,
arrangement_string_count,
base_open_string_midis,
chord_template_to_wire,
@@ -143,9 +144,21 @@ def _sanitize_authors(manifest: dict | None) -> list[dict]:
return out
def _drum_part_id_for_wire(drum_parts: list[dict] | None, selected_id: str | None) -> str | None:
"""Expose a part id only when the pack genuinely has multiple parts."""
return selected_id if selected_id is not None and len(drum_parts or []) > 1 else None
@router.websocket("/ws/highway/{filename:path}")
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"):
"""Stream song data for the highway renderer over WebSocket."""
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
naming_mode: str = "legacy", drum_part: str = ""):
"""Stream song data for the highway renderer over WebSocket.
`drum_part` selects WHICH drum part's tab streams when the pack carries
several (feedpak 1.17.0 "drums as arrangements") a part id from
song_info's `drum_parts`. Empty / unknown ids fall back to the primary,
so a stale or mistyped selection degrades to today's behavior instead of
silencing drums."""
await websocket.accept()
structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8])
@@ -261,9 +274,8 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
bass_idxs = [
i
for i, a in enumerate(song.arrangements)
if getattr(a, "path_bass", False)
if arrangement_is_bass(a)
or (smart_names[i] or "").lower().startswith("bass")
or "bass" in (getattr(a, "name", "") or "").lower()
]
if bass_idxs:
# Among the bass parts: (1) honor the saved default-arrangement
@@ -368,7 +380,9 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
q_fn = quote(filename, safe="")
for s in loaded_slop.stems:
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
stems_payload.append(
{"id": s["id"], "url": url, "default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}})
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
if loaded_slop is not None and loaded_slop.full_mix:
full_mix_url = (
@@ -562,6 +576,15 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
"has_drum_tab": bool(
is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None
),
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"),
# primary first — names only; the selected part's payload streams
# as the `drum_tab`/`drum_hits` messages below. Always a list
# (empty when the pack has no drums, and a single entry for a
# legacy one-drum pack), so a part picker can bind unconditionally.
"drum_parts": [
{"id": p["id"], "name": p["name"]}
for p in (loaded_slop.drum_parts or [])
] if is_slop and loaded_slop is not None else [],
"has_notation": bool(
is_slop
and loaded_slop is not None
@@ -585,18 +608,36 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# client-side drums plugin keeps a fallback decoder for them.
if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None:
dt = loaded_slop.drum_tab
# Multiple drum parts: `?drum_part=<id>` picks which part's tab
# streams; the default (and any unknown id) is the PRIMARY —
# exactly the pre-parts behavior, so legacy clients notice nothing.
_dt_part_id = None
if loaded_slop.drum_parts:
_dt_part_id = loaded_slop.drum_parts[0]["id"]
if drum_part:
for _p in loaded_slop.drum_parts:
if _p["id"] == drum_part:
dt = _p["drum_tab"]
_dt_part_id = _p["id"]
break
kit = drums_mod.normalise_kit(dt.get("kit"))
hits_wire = drums_mod.hits_to_wire(dt.get("hits") or [])
_dt_name = dt.get("name")
_dt_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums"
_dt_msg = {
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
}
# Only multi-part packs identify a part on the wire. Legacy packs
# synthesize a one-item list internally but keep their old frame.
_wire_part_id = _drum_part_id_for_wire(loaded_slop.drum_parts, _dt_part_id)
if _wire_part_id is not None:
_dt_msg["part_id"] = _wire_part_id
try:
await websocket.send_json({
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
})
await websocket.send_json(_dt_msg)
for i in range(0, len(hits_wire), 500):
await websocket.send_json({
"type": "drum_hits",
@@ -733,20 +774,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# (Arrangement.tones, populated by the converter), so read it straight
# off `arr` rather than walking for XML that doesn't exist.
if is_slop:
# `sloppak_tone_changes` builds the (base, sorted changes) pair
# from `Arrangement.tones`, skipping non-string names and
# non-finite/non-numeric times — unit-tested in test_tones.py.
# `sloppak_tone_changes` builds the (base, base_rig, sorted
# changes) triple from `Arrangement.tones`, skipping non-string
# names, non-finite/non-numeric times, and unusable rig ids —
# unit-tested in test_tones.py.
from tones import sloppak_tone_changes
base_name, tone_changes = sloppak_tone_changes(getattr(arr, "tones", None))
base_name, base_rig, tone_changes = sloppak_tone_changes(
getattr(arr, "tones", None)
)
# Send when there's a base tone OR timed changes — a single-tone
# arrangement has a base but no switches, and the highway should
# still be able to show the initial tone.
if tone_changes or base_name:
await websocket.send_json({
payload = {
"type": "tone_changes",
"base": base_name,
"data": tone_changes,
})
}
# `base_rig` is additive (feedpak-spec §6.9) — omitted entirely
# when the chart binds no rig, so consumers that predate the rig
# model see the exact payload they always did.
if base_rig:
payload["base_rig"] = base_rig
await websocket.send_json(payload)
else:
xml_paths = sorted(_xml_walk("*.xml"))
@@ -973,7 +1023,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# base[string] + offset + capo + fret (matches the tuner / open-string
# labels). arrangement_string_count is O(notes), so compute once here.
_base = base_open_string_midis(
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
arrangement_string_count(arr), arrangement_is_bass(arr))
_capo = int(getattr(arr, "capo", 0) or 0)
def _fill_scale_degree(wire: dict, n, t: float) -> None:
+140
View File
@@ -0,0 +1,140 @@
"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030).
A deliberately dumb fan-out room: a JSON text frame received from one client
is forwarded verbatim to every OTHER client connected to the same session id.
The server interprets nothing beyond the limits below message schemas are
owned entirely by consumers. First consumer: splitscreen's LAN follower mode
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
frames from a host window to view-only followers on other LAN devices.
Design points (full spec in the issue):
- Rooms are created on first join and garbage-collected when the last socket
leaves. No history, no replay, no persistence a late joiner simply waits
for the next frame. Consumers that need state on join re-send it themselves
(splitscreen answers every follower ``hello`` with a fresh ``config``).
- That statelessness is what makes consumer crash-recovery work: a host that
relaunches and rejoins the same session id resumes publishing to its
reconnecting subscribers with no server-side coordination, and an idle room
is indistinguishable from a nonexistent one.
- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``);
consumers pick their own id policy (splitscreen uses a short typeable,
persistent room key).
- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and
total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit
sockets are closed with a policy code; the room carries on. A peer that dies
mid-fan-out is dropped without wedging delivery to the rest.
"""
import asyncio
import logging
import re
import time
from fastapi import APIRouter, WebSocket
log = logging.getLogger("feedBack.server")
router = APIRouter()
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$")
# Limits. Sized generously above the first consumer's needs (splitscreen
# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding
# what an open LAN port can be made to do. All module-level so tests (and a
# desperate operator) can override them.
MAX_FRAME_BYTES = 16 * 1024
MAX_CLIENTS_PER_ROOM = 16
MAX_ROOMS = 32
RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket
RATE_BURST = 240.0 # token-bucket burst headroom
# A peer that stops draining its socket would leave send_text() pending
# forever — and since publishers await the fan-out gather, one stalled peer
# would stall every publisher's receive loop behind it. Bounding the send
# turns the stall into an eviction through the normal failed-send drop path.
SEND_TIMEOUT_SECONDS = 5.0
# RFC 6455 close codes.
_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay
_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded
_WS_MSG_TOO_BIG = 1009
_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity
# session_id → {socket: per-socket send lock}. The lock serializes concurrent
# fan-out sends to the same peer (two publishers relaying at once must not
# interleave writes on a third socket's transport).
_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {}
async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None:
async with lock:
await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS)
@router.websocket("/ws/sync/{session_id}")
async def sync_ws(websocket: WebSocket, session_id: str):
"""Join the fan-out room *session_id*; relay every inbound text frame."""
await websocket.accept()
if not _SESSION_ID_RE.fullmatch(session_id):
await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id")
return
# Capacity checks and insertion run with no await between them, so
# concurrent joiners on the event loop can't race past the caps.
room = _rooms.get(session_id)
if room is None:
if len(_rooms) >= MAX_ROOMS:
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions")
return
room = _rooms[session_id] = {}
log.debug("ws_sync: room %s created", session_id)
elif len(room) >= MAX_CLIENTS_PER_ROOM:
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full")
return
room[websocket] = asyncio.Lock()
tokens = RATE_BURST
last_refill = time.monotonic()
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
text = message.get("text")
if text is None:
await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only")
break
if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES:
await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large")
break
now = time.monotonic()
tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC)
last_refill = now
tokens -= 1.0
if tokens < 0:
await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded")
break
peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket]
if not peers:
continue
results = await asyncio.gather(
*(_send_locked(ws, lock, text) for ws, lock in peers),
return_exceptions=True,
)
# A peer that failed mid-send is dropped from the room here; its
# own handler finishes cleanup (the finally below) when its
# receive loop observes the disconnect.
for (peer, _lock), result in zip(peers, results):
if isinstance(result, Exception):
room.pop(peer, None)
finally:
room.pop(websocket, None)
# Guard against deleting a NEW room another joiner created after this
# one emptied (only possible for a dict that is no longer ours).
if not room and _rooms.get(session_id) is room:
del _rooms[session_id]
log.debug("ws_sync: room %s closed", session_id)
+230 -5
View File
@@ -51,12 +51,133 @@ from scan_worker import _relpath, _scan_one
log = logging.getLogger("feedBack.scan")
import json
# ── Directory-signature fast path ─────────────────────────────────────────────
#
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
# file to detect what changed. On a 50k-song library that lives on a slow mount
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
# the "big drive churns on every startup" report.
#
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
# holds them (verified on the target NTFS-3G mount), and so does the addition of
# a subdirectory (a new entry in its parent). So after a scan we record every
# library directory and its mtime; on the next scan we re-stat ONLY those
# directories (a handful, vs 100k file ops). If none changed, the file set is
# unchanged and the whole listing/stat pass is skipped.
#
# The one thing this cannot see is a file edited IN PLACE under the same name —
# that bumps the file's mtime but not its directory's. That is rare for a song
# library (you add and remove packs, you don't rewrite them under the same name),
# and the manual Refresh forces a full scan (force=True) for exactly that case.
def _dir_signature_file() -> Path:
return appstate.config_dir / "scan_dir_signature.json"
def _load_dir_signature() -> dict | None:
try:
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
return data
except (OSError, ValueError):
pass
return None
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
# Keyed by the DLC path so switching libraries never matches a stale
# signature. Best-effort: a failed write just means the next scan is a full
# one, never a wrong one.
try:
_dir_signature_file().write_text(
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
except OSError as e:
log.debug("scan: could not persist dir signature: %s", e)
def _library_dirs(all_songs, dlc: Path) -> set[str]:
"""Every directory whose mtime reflects an add/remove of a library song:
each song's containing directory and all of its ancestors up to the DLC
root (the root itself always included, as "."). Derived from the already-
listed songs no extra filesystem walk. The builtin carve-outs
(tutorials-builtin / minigames-builtin) are absent because the caller
already excluded them from `all_songs`, so a minigame writing a drill there
never invalidates the fast path.
Directory-form songs (loose-song folders, directory sloppak bundles) also
record their OWN directory: a file added/removed/replaced INSIDE the folder
bumps that folder's mtime but not its parent's, so tracking only the parent
would miss an in-place change to such a song. File-form sloppaks (a single
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
stays at a handful of dir stats."""
rels = {"."}
for f in all_songs:
rel = Path(_relpath(f, dlc))
if f.is_dir():
rels.add(rel.as_posix())
parent = rel.parent
rels.add(parent.as_posix())
for anc in parent.parents:
rels.add(anc.as_posix())
return rels
def _has_unextracted_columns() -> bool:
"""True while any `songs` row still carries NULL in a column added by an
additive migration i.e. metadata the current extractor would fill but
that no existing row has yet (currently `bass_tuning_name`).
The tree-signature fast path only asks "did the file set change"; on a
settled library the answer is no forever, so a schema addition would never
reach extraction. This one-row probe forces the full pass exactly until the
backfill completes `put()` writes '' rather than NULL, so it self-clears
after the rescan instead of disabling the fast path permanently."""
try:
from metadata_db import MetadataDB
cond = " OR ".join(f"{c} IS NULL" for c in MetadataDB._EXTRACTION_MARKER_COLS)
row = appstate.meta_db.conn.execute(
f"SELECT 1 FROM songs WHERE {cond} LIMIT 1").fetchone()
except Exception as e:
# A probe failure must not take the scan down; falling back to the fast
# path costs at most a delayed backfill.
log.debug("scan: unextracted-column probe failed: %s", e)
return False
return row is not None
def _record_dir_signature(all_songs, dlc: Path) -> None:
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
_save_dir_signature(dlc, sig)
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
unreadable a vanished recorded dir means the tree changed, so fail to a
full scan rather than a false match."""
out: dict[str, int] = {}
for rel in rels:
try:
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
except OSError:
return None
return out
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
_scan_status = dict(_SCAN_STATUS_INIT)
# Mass-prune guard thresholds for automatic scans (not full rescan).
# Refuse when would_remove >= max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * existing).
# At 50 % of the library (or even a single row on tiny libraries) a sudden
# disappearance almost certainly means a degraded mount, not a real deletion.
_PRUNE_MAX_ABS = 1
_PRUNE_MAX_FRAC = 0.5
def _make_scan_executor():
"""Build the executor for the background metadata scan.
@@ -99,9 +220,17 @@ def _make_scan_executor():
)
def background_scan():
def background_scan(force: bool = False, allow_mass_prune: bool = False):
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
`force` skips the directory-signature fast path and always does the full
listing/stat pass the manual Refresh sets it (see _dir_signature_file).
`allow_mass_prune` permits the scan to prune more than the catastrophic
threshold (_PRUNE_MAX_FRAC of the library). Only set by /api/rescan/full
(explicit user intent); automatic and plain /api/rescan scans leave it
False so a degraded-mount partial listing can't silently wipe the library.
Never sets `_scan_status["running"] = False` ownership of that flag
lives in `_scan_runner` so a `kick_scan()` racing this function's
terminal write cannot observe a stale False and start a second runner.
@@ -121,6 +250,22 @@ def background_scan():
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
# Fast path: if every library directory recorded by the last scan still has
# the same mtime, nothing was added, removed, or renamed, so the whole
# glob-and-stat pass below can be skipped (see the signature comment above).
# `force` (manual Refresh) always does the full pass. Seeding above is
# idempotent — it only writes when a builtin is missing — so it does not
# perturb the mtimes on a settled library.
if not force and not _has_unextracted_columns():
stored = _load_dir_signature()
if stored is not None and stored.get("dlc") == str(dlc):
current = _stat_dirs(dlc, stored["dirs"].keys())
if current is not None and current == stored["dirs"]:
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
len(current))
return
# Listing can fail on macOS without Full Disk Access, or on Docker if the
# path isn't shared. Report the failure explicitly rather than silently
# appearing to scan nothing.
@@ -180,6 +325,43 @@ def background_scan():
current_files = {_relpath(f, dlc) for f in all_songs}
# Guard: refuse (or warn) when the listing suggests a degraded mount.
# Two cases share the same logic:
# 1. Zero listing — current_files empty, DB non-empty: would erase everything.
# 2. Partial listing — current_files non-empty but so many DB rows are absent
# that it looks like a mount glitch rather than deliberate deletions.
# Threshold: would_remove >= max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * existing).
# allow_mass_prune (only True for /api/rescan/full) lets the prune proceed with
# a warning so the user's explicit intent is honoured even in the degraded case.
with appstate.meta_db._lock:
_existing = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
if _existing > 0:
if not current_files:
_would_remove = _existing
else:
with appstate.meta_db._lock:
_db_files = {r[0] for r in appstate.meta_db.conn.execute(
"SELECT filename FROM songs").fetchall()}
_would_remove = len(_db_files - current_files)
_threshold = max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * _existing)
if _would_remove >= _threshold:
_msg = (
f"Scan: would remove {_would_remove} of {_existing} DB rows "
f"(threshold {int(_threshold)}) with only {len(current_files)} song(s) visible "
"— possible mount/permission issue. Check the DLC mount; use Settings → "
"Rescan Library (full) to authorise a large prune."
)
if allow_mass_prune:
log.warning("%s — proceeding (user-authorised full rescan)", _msg)
else:
log.error("%s", _msg)
_scan_status = {**_SCAN_STATUS_INIT, "running": True,
"stage": "error", "error": _msg}
return
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
# + genuinely-new files) so the scan can surface an added/removed summary.
_delta = appstate.meta_db.delete_missing(current_files)
@@ -209,6 +391,15 @@ def background_scan():
cached = None
if not cached:
to_scan.append((f, mtime, size, dlc))
elif any(cached.get(c) is None for c in appstate.meta_db._EXTRACTION_MARKER_COLS):
# Row predates one of the per-perspective tuning columns (NULL
# from the additive migration), so that perspective's tuning was
# never extracted for it. Without this
# re-queue an existing library would keep every bass column empty
# forever — mtime/size still match, so nothing else would ever
# bring the row back through extraction. Converges: put() always
# writes '' (never NULL), so a rescanned row is never re-queued.
to_scan.append((f, mtime, size, dlc))
elif cached.get("arrangements") and any(
"smart_name" not in a for a in cached["arrangements"]
):
@@ -223,6 +414,9 @@ def background_scan():
to_scan.append((f, mtime, size, dlc))
if not to_scan:
# Full pass completed with the DB already up to date — record the tree
# signature so the next startup can take the fast path.
_record_dir_signature(all_songs, dlc)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
return
@@ -247,6 +441,9 @@ def background_scan():
_scan_status["done"] += 1
_scan_status["current"] = fname
# Record the tree signature after a completed full pass so the next startup
# can skip it when nothing has changed.
_record_dir_signature(all_songs, dlc)
log.info("Scan complete: %d songs cached", len(to_scan))
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
@@ -255,6 +452,13 @@ _scan_kick_lock = threading.Lock()
_scan_rescan_pending = False
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
# manual Refresh bypasses the directory-signature fast path.
_scan_force_next = False
# Set by kick_scan(allow_mass_prune=True); allows the next pass to prune past the
# catastrophic threshold. Sticky like _scan_force_next: if any queued request asks
# for it, the follow-up pass honours it.
_scan_mass_prune_next = False
# Handles to the running scan / enrichment worker threads. Both use the shared
@@ -265,9 +469,19 @@ _scan_rescan_pending = False
_scan_thread: threading.Thread | None = None
def kick_scan() -> bool:
def kick_scan(force: bool = False, allow_mass_prune: bool = False) -> bool:
"""Request a library rescan, single-flight + coalescing.
`force` skips the directory-signature fast path for the resulting pass (the
manual Refresh uses it so an in-place same-name edit the one thing the
fast path can't see — is always picked up). A forced request that coalesces
onto a running or queued scan keeps the force intent: the pass is forced if
ANY pending request asked for it.
`allow_mass_prune` permits the resulting pass to prune past the catastrophic
threshold. Sticky: if any pending request set it, the follow-up pass honours it.
Only /api/rescan/full passes True plain rescans and startup scans never do.
Returns True if a new scan thread was started, False if one was already
running. In the latter case a follow-up pass is queued and runs as soon
as the current scan finishes so files landing mid-scan (e.g. an upload
@@ -275,8 +489,12 @@ def kick_scan() -> bool:
until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up.
"""
global _scan_rescan_pending, _scan_thread
global _scan_rescan_pending, _scan_thread, _scan_force_next, _scan_mass_prune_next
with _scan_kick_lock:
if force:
_scan_force_next = True
if allow_mass_prune:
_scan_mass_prune_next = True
if _scan_status["running"]:
_scan_rescan_pending = True
return False
@@ -290,10 +508,17 @@ def kick_scan() -> bool:
def _scan_runner():
"""Run _background_scan, then re-run if requests arrived mid-scan."""
global _scan_rescan_pending
global _scan_rescan_pending, _scan_force_next, _scan_mass_prune_next
while True:
# Consume both flags for THIS pass; requests queued mid-scan set them
# again for the follow-up (sticky: any requester who asked for it wins).
with _scan_kick_lock:
forced = _scan_force_next
_scan_force_next = False
mass_prune = _scan_mass_prune_next
_scan_mass_prune_next = False
try:
background_scan()
background_scan(force=forced, allow_mass_prune=mass_prune)
except Exception:
log.exception("background scan failed unexpectedly")
+59 -1
View File
@@ -27,7 +27,11 @@ import logging
from pathlib import Path
from song import compute_smart_names
from tunings import tuning_name
from tunings import (
DEFAULT_PERSPECTIVE, PERSPECTIVES, ROLE_PERSPECTIVES, normalize_offsets,
perspective_low_pitch, perspective_tuning_key, perspective_tuning_name,
tuning_name,
)
import sloppak as sloppak_mod
import loosefolder as loosefolder_mod
@@ -43,6 +47,56 @@ def _relpath(f: Path, dlc: Path) -> str:
return f.name
def _apply_role_tunings(meta: dict) -> None:
"""Derive each ROLE perspective's tuning columns from the raw offsets the
extractor emitted (currently bass + rhythm; guitar-lead reads the
song-level columns the scanner has always written).
The domain rules live in `tunings` (see the PERSPECTIVES table and the
block above it for the evidence behind each):
1. NORMALIZE FIRST. Stored bass arrays are commonly six elements whose
last two slots are padding, so bass truncates to four strings before
anything looks at them padding must never reach the namer or the
grouping key. Guitar does NOT truncate (a 7-string array is real).
2. Refuse to name data the perspective distrusts (bass up-tuning), so the
library can't send a player off to a tuning nobody plays.
3. Group on CANONICAL PITCHES, not the raw offsets string the same
physical tuning serialized two ways must be ONE facet entry.
A song with no arrangement in that role gets EMPTY strings / 0, not NULL:
'' is the indexed "we looked, there is no such chart" state the library's
fallback keys on, while NULL means "never extracted" and re-scans.
"""
for persp in ROLE_PERSPECTIVES:
raw = meta.pop(f"{persp.role}_tuning_offsets", None)
offsets = normalize_offsets(raw, persp)
if offsets is None:
meta[persp.column("name")] = ""
meta[persp.column("sort_key")] = 0
meta[persp.column("offsets")] = ""
meta[persp.column("key")] = ""
meta[persp.column("low_pitch")] = None
continue
meta[persp.column("name")] = perspective_tuning_name(offsets, persp)
meta[persp.column("sort_key")] = sum(offsets)
# The NORMALIZED offsets are what we store: padding is not data, and a
# client rendering target notes must not print phantom strings.
meta[persp.column("offsets")] = " ".join(str(o) for o in offsets)
meta[persp.column("key")] = perspective_tuning_key(offsets, persp)
meta[persp.column("low_pitch")] = perspective_low_pitch(offsets, persp)
def _apply_song_low_pitch(meta: dict, offsets: list[int]) -> None:
"""Lowest open-string pitch of the SONG-level (guitar-lead) tuning, for
the "playable without retuning" comparison. Indexed here, on the existing
manifest-only pass never by reopening chart JSON."""
persp = PERSPECTIVES[DEFAULT_PERSPECTIVE]
norm = normalize_offsets(offsets, persp)
meta["tuning_low_pitch"] = (
perspective_low_pitch(norm, persp) if norm is not None else None)
def _extract_meta_sloppak(path: Path) -> dict:
"""Extract metadata for a sloppak (file or directory)."""
meta = sloppak_mod.extract_meta(path)
@@ -52,6 +106,8 @@ def _extract_meta_sloppak(path: Path) -> dict:
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
_apply_song_low_pitch(meta, offsets)
_apply_role_tunings(meta)
meta["format"] = "sloppak"
# `extract_meta` already populates `stem_ids` (feedBack#129);
# default to empty for older callers / mocks.
@@ -86,6 +142,8 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
_apply_song_low_pitch(meta, offsets)
_apply_role_tunings(meta)
meta["format"] = "loose"
meta.setdefault("stem_ids", [])
# The library helper exposes absolute filesystem paths for audio/art
+388 -89
View File
@@ -80,6 +80,20 @@ def find_full_mix(stems: list[dict]) -> dict | None:
)
def stem_default_on(raw) -> bool:
"""Whether a manifest stem entry plays by default.
Absent means on. A string is honoured so a hand-written manifest can say
`default: off`. Extracted so the WS `ready` payload and the REST song-info
payload cannot drift: the stems plugin now preloads from REST and then has
to agree with what the WS says a moment later, or it would rebuild the whole
graph for nothing.
"""
if isinstance(raw, str):
return raw.lower() not in ("off", "false", "0", "no")
return bool(raw)
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
@@ -107,6 +121,41 @@ def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
def _resolve_pack_path(source_dir: Path, rel: str, label: str) -> Path | None:
"""Resolve a manifest-relative path, contained inside the pack. None if not.
Every manifest key that names a file routes through here. A crafted manifest
must not read outside the sloppak directory via path traversal
(e.g. `../../etc`), and a symlink loop or permission error on `.resolve()`
must disable that one file rather than abort the whole load so both
failures are caught, and both are warnings rather than raises.
The two branches log differently on purpose: a `ValueError` means the path
resolved *outside* the pack (a crafted or broken manifest), an `OSError`
means it could not be resolved at all (symlink loop, permissions). Reading
"escapes source_dir" in the logs and reading "resolution failed" lead an
operator to very different places, so the distinction is worth two lines.
Returns the resolved path **existence is NOT checked here**. Callers
differ on that deliberately: a missing optional side-file is silent, while a
missing arrangement skips an entry, so each caller keeps its own `.exists()`
(or `.is_file()`) test and its own control flow.
`label` names the manifest key in the log message ("keys", "song_timeline",
a drum part's id, …).
"""
try:
p = (source_dir / rel).resolve()
p.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel)
return None
except OSError as e:
log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e)
return None
return p
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
@@ -138,16 +187,8 @@ def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
if not isinstance(rel_raw, str) or not rel_raw.strip():
return None
rel = rel_raw.strip()
try:
target = (source_dir / rel).resolve()
target.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
return None
if not target.is_file():
target = _resolve_pack_path(source_dir, rel, "original_audio")
if target is None or not target.is_file():
return None
log.info(
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
@@ -684,6 +725,14 @@ class LoadedSloppak:
# absent / unreadable / malformed. Streamed over the highway WS as a
# `keys` message; consumers (renderers, plugins) read it from there.
keys: dict | None = None
# Parsed `rigs.json` payload (manifest `rigs:` key, spec §7.9) — the pack's
# library of engine-agnostic signal chains: effect chains and, since
# feedpak 1.18.0, MIDI-voiced sound sources. Arrangements bind rigs to time
# by referencing a rig `id` from `tones.base_rig` / `tones.changes[].rig`
# (§6.9), which `lib/tones.py` carries onto the wire. None when absent /
# unreadable / malformed. Rig objects are kept verbatim — this loader does
# not select realizations or apply the `intent.gm` floor.
rigs: dict | None = None
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
# None when absent/empty. Streamed over the highway WS (`tempos` /
@@ -716,6 +765,227 @@ class LoadedSloppak:
# separated stems the moment one drops below 100% — demucs recombination is
# lossy, so the mixdown is strictly the better audio when nothing is muted.
full_mix: str | None = None
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"): one dict
# {"id", "name", "drum_tab"} per part, primary FIRST. A part comes from a
# `type: drums` arrangement entry carrying a per-arrangement `drum_tab`
# file pointer and NO note `file` — entries this loader deliberately never
# turns into fretted Arrangements (see the file/notation gate in
# load_song; that skip IS the grading invariant). The primary part's
# payload is the SAME object as `drum_tab` above (the song-level key is
# its back-compat alias). None when the pack has no drums at all; a
# single-part list for a legacy pack with only the song-level key.
drum_parts: list[dict] | None = None
def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
"""Load + schema-validate one drum-tab JSON named by a manifest-relative
path. Shared by the song-level `drum_tab:` key and the per-arrangement
drum-part pointers (feedpak 1.17.0), so every tab gets the same posture:
permissive a missing file disables that part silently; a traversal,
parse, or validation failure disables it with a warning, never aborting
the load."""
dt_path = _resolve_pack_path(source_dir, rel, label)
if dt_path is None or not dt_path.exists():
return None
try:
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse %s %r: %s", label, rel, e)
return None
ok, reason = drums_mod.validate_drum_tab(raw)
if not ok:
log.warning("sloppak: %s %r failed validation: %s", label, rel, reason)
return None
return raw
def _load_rigs_file(source_dir: Path, rel: str) -> dict | None:
"""Load the pack's rig library (manifest `rigs:` key, spec §7.9).
Returns `{"version": int, "rigs": [...]}` or None. Same permissive posture
as every other side-file: missing / unreadable / malformed -> None, never
fatal spec §7.9 is explicit that a rig library a Reader can't use MUST NOT
fail the pack.
Rig objects are kept **verbatim**. Only entries that could never be
addressed are dropped a rig is reachable solely by `id` (from
`tones.base_rig` / `changes[].rig`), so a non-dict entry or one without a
usable string id is unreferenceable by construction. Everything else,
including unknown `role` / `engine` / `kind` values and `ext` namespaces,
passes through untouched, because this loader does not interpret rigs:
realization selection and the `intent.gm` fallback belong to whatever
voices the part.
"""
try:
r_path = (source_dir / rel).resolve()
r_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: rigs path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: rigs path resolution failed (%s) — skipped", e)
return None
if not r_path.exists():
return None
try:
raw = load_json(r_path)
except Exception as e:
log.warning("sloppak: failed to parse rigs %r: %s", rel, e)
return None
if not isinstance(raw, dict):
log.warning("sloppak: rigs %r ignored — expected dict, got %s",
rel, type(raw).__name__)
return None
if not isinstance(raw.get("rigs"), list):
log.warning("sloppak: rigs %r ignored — 'rigs' must be a list", rel)
return None
clean_rigs: list[dict] = []
seen: set[str] = set()
for rig in raw["rigs"]:
if not isinstance(rig, dict):
continue
rid = rig.get("id")
if not isinstance(rid, str) or not rid.strip():
continue
# Normalize the library side of the lookup the same way the reference
# side is normalized in lib/tones.py — otherwise a pack with padded ids
# fails to resolve against a stripped `base_rig` / `rig`.
rid = rid.strip()
# A duplicate id makes `tones.base_rig` ambiguous, which would surface
# as the wrong sound rather than an error. First wins, loudly.
if rid in seen:
log.warning("sloppak: rigs %r has duplicate rig id %r — later one ignored",
rel, rid)
continue
seen.add(rid)
clean_rigs.append({**rig, "id": rid})
# int only — a float version (incl. NaN/Inf, which json.loads accepts)
# would raise on int(); default rather than abort an optional side-file.
_ver = raw.get("version")
return {
"version": _ver if isinstance(_ver, int) and not isinstance(_ver, bool) else 1,
"rigs": clean_rigs,
}
def _entry_tones(entry: dict) -> dict | None:
"""A manifest entry's `tones` binding, or None when it doesn't carry one.
Spec §5.2: a manifest arrangement entry's `tones` overrides the arrangement
JSON's `tones` **wholesale** — no field-level merge. This normalizes the
"does it carry one" test for both the arrangement path and the drum path.
An empty dict reads as *absent*, not as "override to silence": it is what a
Writer emits by accident, `arrangement_from_wire` already normalizes the
in-JSON `{}` to None the same way, and treating it as an override would let
a stray empty object silently unbind a part's sound.
"""
tones = entry.get("tones")
return tones if isinstance(tones, dict) and tones else None
def _resolve_drum_parts(
source_dir: Path,
drum_tab_rel: object,
drum_tab_data: dict | None,
drum_pointer_entries: list[dict],
drum_tones: dict | None = None,
) -> tuple[dict | None, list[dict] | None]:
"""Resolve drum pointers into a primary-first list with unique ids.
Also binds each part's sound (feedpak 1.18.0). The precedence mirrors the
`drum_tab` alias rule this function already implements: a `type: drums`
entry's own `tones` wins for that part, and the song-level `drum_tones` is
the fallback for the **primary** part only. A Reader MUST NOT apply both to
the same part (spec §5.1/§5.2), which is why the primary picks one or the
other here rather than merging them.
"""
if drum_tab_data is None and not drum_pointer_entries:
return drum_tab_data, None
primary_id = "drums"
primary_name = None
# The primary's own binding, lifted from its alias pointer entry when it has
# one. Stays None if no entry claims the primary — `drum_tones` fills in.
primary_tones = None
extra_parts: list[dict] = []
seen_rels: set[str] = set()
# Use the same canonical, traversal-safe identity as zip member lookup so
# equivalent spellings ("x.json", "./x.json", or backslashes) identify
# one file. Otherwise an alias pointer can reload and duplicate the primary.
primary_rel_key = (
_zip_member_key(drum_tab_rel.strip())
if isinstance(drum_tab_rel, str) and drum_tab_rel.strip() else None
)
for entry in drum_pointer_entries:
rel = str(entry.get("drum_tab") or "").strip()
rel_key = _zip_member_key(rel) if rel else None
rel_identity = rel_key or rel
if not rel or rel_identity in seen_rels:
continue
seen_rels.add(rel_identity)
entry_id = str(entry.get("id") or "").strip()
entry_name = str(entry.get("name") or "").strip()
if primary_rel_key is not None and rel_key == primary_rel_key:
if entry_id:
primary_id = entry_id
if entry_name:
primary_name = entry_name
# This entry IS the primary (an alias pointer at the same file), so
# its binding is the primary's — and it outranks `drum_tones`.
_alias_tones = _entry_tones(entry)
if _alias_tones is not None:
primary_tones = _alias_tones
continue
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
if tab is None:
continue
tab_name = tab.get("name")
extra_parts.append({
"id": entry_id,
"name": entry_name
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
"drum_tab": tab,
# Non-primary parts bind through their own entry only; `drum_tones`
# is explicitly the primary's fallback, never theirs.
"tones": _entry_tones(entry),
})
parts: list[dict] = []
used_ids: set[str] = set()
if drum_tab_data is not None:
if primary_name is None:
tab_name = drum_tab_data.get("name")
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
parts.append({
"id": primary_id,
"name": primary_name,
"drum_tab": drum_tab_data,
# Entry `tones` takes precedence; `drum_tones` is the fallback. One
# or the other, never both on the same part (spec §5.1).
"tones": primary_tones if primary_tones is not None else drum_tones,
})
used_ids.add(primary_id)
next_generated_id = 2
for part in extra_parts:
part_id = part["id"]
if not part_id or part_id in used_ids:
while f"drums-{next_generated_id}" in used_ids:
next_generated_id += 1
part_id = f"drums-{next_generated_id}"
next_generated_id += 1
part["id"] = part_id
used_ids.add(part_id)
parts.append(part)
if not parts:
return drum_tab_data, None
if drum_tab_data is None:
drum_tab_data = parts[0]["drum_tab"]
return drum_tab_data, parts
def load_song(
@@ -740,6 +1010,7 @@ def load_song(
notation_acc: dict[str, dict] = {}
any_notation = False
arrangement_ids_acc: list[str | None] = [] # parallel to song.arrangements
drum_pointer_entries: list[dict] = [] # feedpak 1.17.0 drum-part pointers
for entry in manifest.get("arrangements", []) or []:
if not isinstance(entry, dict):
log.warning("sloppak: non-dict arrangement entry skipped (%r)", type(entry).__name__)
@@ -748,20 +1019,35 @@ def load_song(
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
notation_raw = entry.get("notation")
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
if not rel and not has_notation_key:
_etype = str(entry.get("type") or "").strip().lower()
is_drums = _etype in ("drums", "drum")
# A drums-typed entry MUST NEVER become a fretted Arrangement (grading
# invariant, spec §5.2/§7.5): route on `type` FIRST, not on file
# absence — a malformed drums entry that also carries a note file/
# notation would otherwise fall through and grade as garbage.
if is_drums or (not rel and not has_notation_key):
# A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as
# arrangements"): `type: drums` with a per-arrangement `drum_tab`
# file. Collect it for the drum-parts load after this loop.
if is_drums and isinstance(entry.get("drum_tab"), str):
drum_pointer_entries.append(entry)
elif is_drums:
# Drums-typed but no drum_tab pointer — drop it (any note
# file/notation it carries is ignored), never fret it.
log.warning(
"sloppak: drums-typed arrangement entry %r has no drum_tab pointer — dropped",
entry.get("id"),
)
elif isinstance(entry.get("drum_tab"), str):
log.warning(
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
entry.get("drum_tab"), entry.get("type"),
)
continue
data = None
if rel:
try:
arr_path = (source_dir / rel).resolve()
arr_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: arrangement path %r escapes source_dir — skipped", rel)
continue
except OSError as e:
log.warning("sloppak: arrangement path resolution failed (%s) — skipped", e)
continue
if not arr_path.exists():
arr_path = _resolve_pack_path(source_dir, rel, "arrangement")
if arr_path is None or not arr_path.exists():
continue
try:
data = load_json(arr_path)
@@ -778,6 +1064,11 @@ def load_song(
# the arrangement JSON (name, tuning, capo, centOffset).
if entry.get("name"):
arr.name = str(entry["name"])
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335).
# Drives arrangement_string_count's bass fallback so a bass authored on
# an arrangement whose NAME doesn't say "bass" still reports 4 strings.
if entry.get("type"):
arr.type = str(entry["type"]).strip().lower()
if "tuning" in entry:
arr.tuning = list(entry["tuning"])
if "capo" in entry:
@@ -786,6 +1077,14 @@ def load_song(
# _finite_float keeps a malformed manifest NaN/Infinity from
# poisoning the song_info JSON (same guard as the wire path).
arr.cent_offset = _finite_float(entry["centOffset"])
# `tones` overrides WHOLESALE, unlike the field-level overrides above:
# the entry's object replaces the arrangement JSON's entirely, with no
# per-field merge (spec §5.2). A Writer SHOULD NOT emit both, but when
# one does, a half-merged sound — this pack's base with that pack's
# changes — would be worse than either source alone.
_entry_tone_block = _entry_tones(entry)
if _entry_tone_block is not None:
arr.tones = _entry_tone_block
# Beats/sections can live on the arrangement itself in the wire format.
# If the manifest-level arrangement JSON carries them, pull them onto
@@ -818,15 +1117,7 @@ def load_song(
notation_rel = notation_rel.strip()
if not notation_rel:
continue
try:
nt_path = (source_dir / notation_rel).resolve()
nt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: notation path %r escapes source_dir — skipped", notation_rel)
nt_path = None
except OSError as e:
log.warning("sloppak: notation path resolution failed (%s) — skipped", e)
nt_path = None
nt_path = _resolve_pack_path(source_dir, notation_rel, "notation")
raw_nt = None
if nt_path is not None and nt_path.exists():
try:
@@ -854,32 +1145,20 @@ def load_song(
drum_tab_data: dict | None = None
drum_tab_rel = manifest.get("drum_tab")
if isinstance(drum_tab_rel, str) and drum_tab_rel:
# Constrain to source_dir to prevent a crafted manifest from reading
# files outside the sloppak directory via path traversal (e.g. ../../etc).
# Wrap both resolve() calls in a broad handler: symlink loops and
# permission errors on .resolve() should disable drums, not abort load.
try:
dt_path = (source_dir / drum_tab_rel).resolve()
dt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: drum_tab path %r escapes source_dir — skipped", drum_tab_rel)
dt_path = None
except OSError as e:
log.warning("sloppak: drum_tab path resolution failed (%s) — skipped", e)
dt_path = None
if dt_path is not None and dt_path.exists():
try:
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
raw = None
if raw is not None:
ok, reason = drums_mod.validate_drum_tab(raw)
if ok:
drum_tab_data = raw
else:
log.warning("sloppak: drum_tab %r failed validation: %s",
drum_tab_rel, reason)
drum_tab_data = _load_drum_tab_file(source_dir, drum_tab_rel, "drum_tab")
# Keep the dense compatibility logic independently testable and guarantee
# ids are unique before the highway exposes them as selectors.
# Top-level `drum_tones` (spec §5.1) binds the song-level drum part — the
# fallback for packs without `type: drums` arrangements. Same shape as an
# arrangement entry's `tones`; `_resolve_drum_parts` owns the precedence.
_raw_drum_tones = manifest.get("drum_tones")
drum_tones_data = _raw_drum_tones if isinstance(_raw_drum_tones, dict) and _raw_drum_tones else None
drum_tab_data, drum_parts = _resolve_drum_parts(
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
drum_tones_data,
)
# Drum-only sloppak: every GP track was percussion, so it ships a
# drum_tab but no pitched arrangements. The highway WS rejects an empty
@@ -918,15 +1197,7 @@ def load_song(
time_sigs_data: list | None = None
song_timeline_rel = manifest.get("song_timeline")
if isinstance(song_timeline_rel, str) and song_timeline_rel:
try:
st_path = (source_dir / song_timeline_rel).resolve()
st_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: song_timeline path %r escapes source_dir — skipped", song_timeline_rel)
st_path = None
except OSError as e:
log.warning("sloppak: song_timeline path resolution failed (%s) — skipped", e)
st_path = None
st_path = _resolve_pack_path(source_dir, song_timeline_rel, "song_timeline")
if st_path is not None and st_path.exists():
try:
raw = load_json(st_path)
@@ -1016,15 +1287,7 @@ def load_song(
# downstream through the WS path.
lyrics_rel = manifest.get("lyrics")
if isinstance(lyrics_rel, str) and lyrics_rel:
try:
lyr_path = (source_dir / lyrics_rel).resolve()
lyr_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: lyrics path %r escapes source_dir — skipped", lyrics_rel)
lyr_path = None
except OSError as e:
log.warning("sloppak: lyrics path resolution failed (%s) — skipped", e)
lyr_path = None
lyr_path = _resolve_pack_path(source_dir, lyrics_rel, "lyrics")
if lyr_path is not None and lyr_path.exists():
try:
raw = load_json(lyr_path)
@@ -1100,12 +1363,19 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
default_val = s.get("default", True)
if isinstance(default_val, str):
default_on = default_val.lower() not in ("off", "false", "0", "no")
else:
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
entry = {
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
}
# Optional presentational fields (feedpak 1.16.0, spec §5.3). Omitted —
# not None — when absent, so payload builders can pass entries through
# without every stem growing null keys.
for key in ("name", "description"):
val = s.get(key)
if isinstance(val, str) and val.strip():
entry[key] = val
stems.append(entry)
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
# it out so that no consumer of `stems` — the mixer, the library's stem
@@ -1122,15 +1392,7 @@ def load_song(
keys_data: dict | None = None
keys_rel = manifest.get("keys")
if isinstance(keys_rel, str) and keys_rel:
try:
k_path = (source_dir / keys_rel).resolve()
k_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: keys path %r escapes source_dir — skipped", keys_rel)
k_path = None
except OSError as e:
log.warning("sloppak: keys path resolution failed (%s) — skipped", e)
k_path = None
k_path = _resolve_pack_path(source_dir, keys_rel, "keys")
if k_path is not None and k_path.exists():
try:
raw = load_json(k_path)
@@ -1175,6 +1437,14 @@ def load_song(
"events": clean_events,
}
# Optional rigs.json — the pack's rig library (manifest `rigs:` key,
# spec §7.9). Loaded here so the highway WS can hand it to whatever voices
# the part; the bindings that reference it ride the arrangement's `tones`.
rigs_data: dict | None = None
rigs_rel = manifest.get("rigs")
if isinstance(rigs_rel, str) and rigs_rel:
rigs_data = _load_rigs_file(source_dir, rigs_rel)
_fpv = manifest.get("feedpak_version")
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
# above (spec §5.3) — no path work needed, it was validated with the other
@@ -1200,10 +1470,12 @@ def load_song(
manifest=manifest,
feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None,
drum_tab=drum_tab_data,
drum_parts=drum_parts,
song_timeline=song_timeline_data,
tempos=tempos_data,
time_signatures=time_sigs_data,
keys=keys_data,
rigs=rigs_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
full_mix=full_mix_data,
@@ -1227,6 +1499,27 @@ def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
return [0] * 6
def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None:
"""Per-ROLE companion to _tuning_for_meta: the tuning of the arrangement
playing `role` ("bass" / "rhythm"), or None when the pack has no such
arrangement with a tuning the index then leaves that perspective's
columns empty and the library falls back to the song (guitar-first)
tuning, marking the row inferred.
Exact name first, then a looser containment pass so an alt/bonus chart
("Bass 2", "Alt Rhythm") still beats pretending the part is in the lead
guitar's tuning."""
for match_exact in (True, False):
for entry in arrangements_manifest:
name = str(entry.get("name", "")).lower()
tun = entry.get("tuning")
if not (tun and isinstance(tun, list)):
continue
if name == role if match_exact else role in name:
return list(tun)
return None
def extract_meta(path: Path) -> dict:
"""Fast metadata for the library scanner. Reads only the manifest."""
manifest = load_manifest(path)
@@ -1249,6 +1542,10 @@ def extract_meta(path: Path) -> dict:
has_lyrics = bool(manifest.get("lyrics"))
tuning_offsets = _tuning_for_meta(arr_list)
# Per-role tunings alongside the song-level one, so the library can answer
# for whichever arrangement the player actually plays.
role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
for role in ("bass", "rhythm")}
stems_list = manifest.get("stems", []) or []
valid_stems: list[dict] = []
@@ -1287,6 +1584,8 @@ def extract_meta(path: Path) -> dict:
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
"duration": float(manifest.get("duration", 0) or 0),
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
# None = the pack has no arrangement in that role.
**role_tunings,
"arrangements": arrangements,
"has_lyrics": has_lyrics,
"stem_count": stem_count,
+58 -7
View File
@@ -56,6 +56,13 @@ class Note:
strum_group: int = -1
scale_degree: int = -1
ignore: bool = False
# Keys hand assignment ('lh'/'rh', None = unassigned) — authored per-note,
# e.g. from a MusicXML grand staff import in the editor. Lets the notation
# hand split and hands-separate practice honor the author instead of the
# mean-pitch heuristic. Distinct from `right_hand` (the bass plucking
# finger); spelled-out `hand` on the wire because `rh` is taken.
# Default-omitted on the wire; older readers ignore it.
hand: str | None = None
@dataclass
@@ -175,6 +182,12 @@ class Arrangement:
# `base`/`changes` drive the highway tone-change markers; `definitions`
# feed the Tones plugin gear panel.
tones: dict | None = None
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335):
# "bass" | "guitar" | "piano" | "keys" | "drums" | "". First-class DATA that
# lets a user author an instrument on an arrangement whose NAME doesn't say
# so. Lifted from the sloppak manifest entry by sloppak.load_song(); "" for
# archive/loose sources, which instead carry the path_* flags below.
type: str = ""
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
path_lead: bool = False
@@ -272,6 +285,10 @@ def note_to_wire(n: Note) -> dict:
out["ch"] = n.strum_group
if n.scale_degree != -1:
out["sd"] = n.scale_degree
# Keys hand assignment — default-omitted; validated on emit so a
# directly-constructed Note can't put junk ('LH', True, …) on the wire.
if n.hand in ("lh", "rh"):
out["hand"] = n.hand
return out
@@ -492,8 +509,8 @@ def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
O(notes) via ``arrangement_string_count`` for a whole arrangement, hoist
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
per note instead."""
is_bass = "bass" in (arr.name or "").lower()
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
base = base_open_string_midis(arrangement_string_count(arr),
arrangement_is_bass(arr))
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
arr.tuning or [], note.string, note.fret)
@@ -532,6 +549,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
strum_group=_wire_int_optional(d.get("ch"), -1),
scale_degree=_wire_int_optional(d.get("sd"), -1),
ignore=bool(d.get("ig", False)),
# Keys hand assignment — strict enum decode: anything but 'lh'/'rh'
# (junk, wrong case, bools) falls back to unassigned rather than
# poisoning downstream hand-split/practice logic.
hand=d.get("hand") if d.get("hand") in ("lh", "rh") else None,
)
@@ -618,6 +639,23 @@ def phrase_from_wire(d: dict) -> Phrase:
)
def arrangement_is_bass(arr: Arrangement) -> bool:
"""Whether ``arr`` is a bass, most-authoritative signal first: an
editor-authored ``type == "bass"`` (feedpak-spec §5.2 / editor PR #335,
lifted onto sloppaks by :func:`sloppak.load_song`), then the archive
``path_bass`` <arrangementProperties> flag, then the legacy "bass"
case-insensitive substring in the name. Single source of the bass decision
so string-count derivation and the open-string pitch base (via
:func:`base_open_string_midis`) agree a bass authored on an arrangement
whose NAME doesn't say "bass" must get both 4 lanes AND the bass MIDI base,
not 4 lanes on a guitar octave."""
return (
(arr.type or "").strip().lower() == "bass"
or bool(arr.path_bass)
or "bass" in (arr.name or "").lower()
)
def arrangement_string_count(arr: Arrangement) -> int:
"""Derive the active arrangement's string count.
@@ -635,10 +673,17 @@ def arrangement_string_count(arr: Arrangement) -> int:
But this is a LOWER BOUND only a 6-string lead chart that
never plays string 5 reports 5, undercounting by 1.
2. **Name-based fallback.** Arrangements named "Bass" (case-
insensitive substring match) default to 4; everything else
defaults to 6. This catches the partial-string-usage case
where notes don't span all the instrument's strings.
2. **Instrument-type fallback.** An arrangement whose authoritative
instrument signal says bass defaults to 4; everything else
defaults to 6. This catches the partial-string-usage case where
notes don't span all the instrument's strings. The bass signal is
any of: an editor-authored ``type == "bass"`` (feedpak-spec §5.2 /
editor PR #335 — lifted onto sloppaks by ``sloppak.load_song``),
the ``path_bass`` <arrangementProperties> flag (archive/DLC
sources), or the legacy "bass" case-insensitive substring in the
name. Trusting ``type``/``path_bass`` closes the gap where a user
authors a bass instrument on an arrangement whose NAME doesn't say
"bass" (the editor lays out 4 lanes; core must agree).
A third signal ``len(arr.tuning)`` when it isn't the arrangement XML
padded value of 6 folds in for sloppak / GP-imported sources
@@ -669,6 +714,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
max(0, 4, 0) = 4
* Empty arrangement named "Lead" (tuning len 6)
max(0, 6, 0) = 6
* Editor-authored bass named "Low End" (type "bass", tuning len 6,
notes 0..3) name_based=4 max(4, 4, 0) = 4
* DLC bass named "Low End" (pathBass flag set, tuning len 6, notes
0..3) name_based=4 max(4, 4, 0) = 4
Topkoa's issue argues plugins shouldn't do arrangement-name
matching; server-side fallback IS the right place for it
@@ -684,7 +733,9 @@ def arrangement_string_count(arr: Arrangement) -> int:
if cn.string > max_s:
max_s = cn.string
notes_count = max_s + 1 if max_s >= 0 else 0
name_based = 4 if "bass" in arr.name.lower() else 6
# Bass signal (type / pathBass / name substring) — see arrangement_is_bass.
# Any one being bass pulls the fallback to 4.
name_based = 4 if arrangement_is_bass(arr) else 6
# Tuning-length signal — only trustworthy when NOT the arrangement XML
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
# bass; length 7/8 indicates an extended-range guitar from GP.
+25 -9
View File
@@ -32,20 +32,29 @@ def tokens(s: str) -> set[str]:
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t}
def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
def sloppak_tone_changes(arr_tones) -> tuple[str, str, list[dict]]:
"""Build the highway tone-change payload from an arrangement's tone block.
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
returns ``(base, changes)`` where ``base`` is the initial tone name and
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names,
non-dict entries, and non-numeric / non-finite times are skipped a
hand-edited or third-party sloppak must not crash the highway WebSocket
or emit NaN/inf (which the client's ``JSON.parse`` rejects).
returns ``(base, base_rig, changes)`` where ``base`` is the initial tone
name, ``base_rig`` is the ``rigs.json`` rig id bound to it (feedpak-spec
§6.9; ``""`` when absent), and ``changes`` is a time-sorted
``[{"t", "name", "rig"?}]`` list. Non-string names, non-dict entries, and
non-numeric / non-finite times are skipped a hand-edited or third-party
sloppak must not crash the highway WebSocket or emit NaN/inf (which the
client's ``JSON.parse`` rejects).
``rig`` / ``base_rig`` are carried through but NOT resolved against
``rigs.json`` here: this builder only preserves the binding the chart
declared. Realization selection and the ``intent.gm`` fallback (§7.9) belong
to the consumer that actually voices the part.
"""
if not isinstance(arr_tones, dict):
return "", []
return "", "", []
base_val = arr_tones.get("base", "")
base = base_val.strip() if isinstance(base_val, str) else ""
base_rig_val = arr_tones.get("base_rig", "")
base_rig = base_rig_val.strip() if isinstance(base_rig_val, str) else ""
changes: list[dict] = []
raw_changes = arr_tones.get("changes")
@@ -65,6 +74,13 @@ def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
continue
if not math.isfinite(t):
continue
changes.append({"t": round(t, 3), "name": name})
change = {"t": round(t, 3), "name": name}
# ponytail: `rig` only when it's a usable id — a non-string or blank
# value is dropped rather than forwarded, so a consumer can treat
# presence of the key as "this change binds a rig".
rig = c.get("rig")
if isinstance(rig, str) and rig.strip():
change["rig"] = rig.strip()
changes.append(change)
changes.sort(key=lambda x: x["t"])
return base, changes
return base, base_rig, changes
+239 -8
View File
@@ -416,27 +416,258 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
})
return out
def tuning_name(offsets: list[int]) -> str:
# All three pattern checks below are gated on `len(offsets) == 6`. The
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
# 7+-string community content falls through to the numeric fallback. See #43.
# ── Bass tuning normalization (library indexing) ─────────────────────────────
#
# Bass charts in the wild store SIX-element tuning arrays even when the chart is
# a 4-string part: slots 4-5 are PADDING. Confirmed by inspecting the charts
# themselves — across every pack whose bass and guitar tunings diverge, no bass
# note ever references string index 4 or 5 (the deepest reach is index 3).
#
# The feedpak spec carries NO string-count field (manifest `arrangement.tuning`
# is an untyped integer array, `minItems: 1`), and counting strings for real
# would mean parsing the 600KB-1.2MB arrangement JSON of every song on the
# manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS
# TO 4 STRINGS and truncate.
#
# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is
# truncated to its low four. That is harmless for the overwhelmingly common
# case — a 5-string in standard truncates to [0,0,0,0] and still names
# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above.
# Revisit if the spec ever gains a string count.
BASS_DEFAULT_STRING_COUNT = 4
# Standard tunings (all six strings same offset)
# Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights
# string tension. Anything above +1 semitone across the board is data we do not
# trust, not a tuning a human plays (the real-world example that motivated this
# is a bass array of [5,5,5,5,4,4] — "all four strings up a perfect fourth" —
# on a song whose guitar chart is dead standard and whose own note content is
# consistent with standard tuning; the offsets were almost certainly computed
# against a 6-string-bass reference with an uninitialised tail).
#
# Such a tuning MUST NOT be named: printing "A Standard" would send a player
# off to retune to something nobody plays. It degrades to the custom path,
# where it stays visible and distinct but makes no pitch claim.
BASS_MAX_PLAUSIBLE_OFFSET = 1
# ── Tuning PERSPECTIVES ──────────────────────────────────────────────────────
#
# The library's tuning facet/filter/sort always answers for ONE arrangement
# role. There are three, matching `active_instrument_profile`:
#
# guitar-lead the song-level (guitar-first) tuning — the historical
# default. Its columns are the original unprefixed
# `tuning_*` family, so today's behaviour is byte-identical.
# guitar-rhythm the RHYTHM chart's own tuning. Lead and rhythm charts can
# disagree (the same bug a bassist hit, inside guitar).
# bass the BASS chart's own tuning.
#
# One table drives extraction, the derived columns, the SQL, and the labels —
# rather than three near-identical column families maintained in parallel.
class TuningPerspective:
__slots__ = ("id", "role", "instrument", "string_count", "column_prefix",
"truncate", "guard_up_tuning", "label")
def __init__(self, id, role, instrument, string_count, column_prefix,
truncate, guard_up_tuning, label):
self.id = id
self.role = role # arrangement name to look for ('' = song-level)
self.instrument = instrument
self.string_count = string_count
self.column_prefix = column_prefix # '' | 'rhythm_' | 'bass_'
self.truncate = truncate
self.guard_up_tuning = guard_up_tuning
self.label = label
@property
def instrument_key(self) -> str:
return instrument_key(self.instrument, self.string_count)
def column(self, suffix: str) -> str:
return f"{self.column_prefix}tuning_{suffix}"
PERSPECTIVES: dict[str, TuningPerspective] = {
"guitar-lead": TuningPerspective(
"guitar-lead", "", "guitar", 6, "", False, False, "lead"),
"guitar-rhythm": TuningPerspective(
"guitar-rhythm", "rhythm", "guitar", 6, "rhythm_", False, False, "rhythm"),
# Bass alone truncates (padded arrays) and guards against up-tuned data —
# both are bass-specific findings, see the block above.
"bass": TuningPerspective(
"bass", "bass", "bass", BASS_DEFAULT_STRING_COUNT, "bass_", True, True, "bass"),
}
DEFAULT_PERSPECTIVE = "guitar-lead"
# Perspectives that carry their OWN indexed columns (guitar-lead reads the
# song-level ones, which the scanner has always written).
ROLE_PERSPECTIVES = tuple(p for p in PERSPECTIVES.values() if p.column_prefix)
def perspective(perspective_id) -> TuningPerspective:
"""Resolve a perspective id, tolerating the legacy two-valued vocabulary
('guitar' -> guitar-lead) and anything unknown (-> the default). An
unrecognised value must never change filter semantics."""
if perspective_id in PERSPECTIVES:
return PERSPECTIVES[perspective_id]
if perspective_id == "guitar":
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
def normalize_offsets(offsets, persp: TuningPerspective) -> list[int] | None:
"""Coerce a stored tuning array to the strings the perspective's
instrument actually has. Returns None for anything unusable (empty /
non-integer / too short), so callers leave the index empty rather than
record a guess."""
if not isinstance(offsets, list) or not offsets:
return None
if any(isinstance(o, bool) for o in offsets):
return None
try:
vals = [int(o) for o in offsets]
except (TypeError, ValueError):
return None
if len(vals) < persp.string_count:
return None
# Only bass truncates: its arrays are padded (see above). A guitar array
# longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would
# invent a tuning the chart does not have.
if persp.truncate:
return vals[:persp.string_count]
return vals
def offsets_are_plausible(offsets: list[int], persp: TuningPerspective) -> bool:
"""False for data the perspective refuses to trust — currently only the
bass up-tuning guard (see BASS_MAX_PLAUSIBLE_OFFSET)."""
if not persp.guard_up_tuning:
return True
return all(o <= BASS_MAX_PLAUSIBLE_OFFSET for o in offsets)
def perspective_tuning_name(offsets: list[int], persp: TuningPerspective) -> str:
"""Name a NORMALIZED tuning for this perspective, refusing to name data the
perspective distrusts that becomes "Custom Tuning", which stays distinct
by its canonical pitches without asserting a tuning anyone plays."""
if not offsets_are_plausible(offsets, persp):
return "Custom Tuning"
return tuning_name(offsets)
def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str:
"""CANONICAL grouping key: the tuning's absolute open-string pitches, so
the same physical tuning groups as ONE facet entry no matter how it was
serialized. Keyed on pitch rather than the raw offsets string, which is
serialization-dependent and fragments.
Joined with ':' and NOT ',' this key travels back as a `tunings` filter
selector, and that query param is a COMMA-separated list, so a comma here
would be split into meaningless fragments and match nothing.
"""
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
if not midis:
return ""
return persp.id + ":" + ":".join(str(m) for m in midis)
def perspective_low_pitch(offsets: list[int], persp: TuningPerspective) -> int | None:
"""Absolute MIDI pitch of the tuning's LOWEST open string — the value the
"playable without retuning" comparison is built on (see
`chart_is_playable_in`)."""
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
if not midis:
return None
return min(midis)
# ── "Playable without retuning" ──────────────────────────────────────────────
#
# What the player actually wants is "don't make me retune", not "match this
# label". A chart is playable as-is when every pitch it needs is reachable on
# the instrument as currently tuned.
#
# WHAT WE CAN HONESTLY COMPUTE. We index open-string TUNINGS, not the notes a
# chart plays — note data lives in the 600KB-1.2MB arrangement JSON, and the
# library scan is deliberately manifest-only, so we do not read it (indexing a
# per-song lowest note would mean opening every chart on every scan).
#
# So the comparison is on OPEN-STRING PITCH, with a conservative assumption:
# a chart may require its own lowest open string. That gives
#
# playable <=> your lowest open pitch <= the chart's lowest open pitch
#
# On a fretted instrument every pitch ABOVE your lowest open string is
# reachable by fretting (strings sit within an octave of each other and the
# neck gives ~2 octaves), so the low end is the binding constraint. This is
# exactly the dominant real case: a 5-string bass (low B) plays every 4-string
# standard chart AND every drop-D chart untouched, because the low D is just
# fretted on the B string.
#
# DELIBERATE LIMITATIONS, both erring toward NOT claiming playability:
# * A chart that never actually touches its lowest open string is excluded
# anyway. Conservative: excluding a playable chart costs a scroll;
# including an unplayable one costs a mid-practice retune, which is the
# failure this feature exists to prevent.
# * The UPPER bound is not checked — a chart tuned far above you could in
# principle exceed your neck. Checking it needs the note range we do not
# have. It is the rare direction (and the guard above already refuses
# up-tuned bass data), but it is a real gap, not an oversight.
def chart_is_playable_in(chart_low_pitch, your_low_pitch) -> bool:
"""True when a chart whose lowest open string is `chart_low_pitch` needs no
retune for a player tuned to `your_low_pitch`. Unknown chart pitch => False
(never claim playability we cannot support)."""
if chart_low_pitch is None or your_low_pitch is None:
return False
return int(your_low_pitch) <= int(chart_low_pitch)
# Back-compat wrappers over the generic helpers — bass was the first
# perspective and reads better spelled out at bass-specific call sites.
def normalize_bass_offsets(offsets) -> list[int] | None:
return normalize_offsets(offsets, PERSPECTIVES["bass"])
def bass_offsets_are_plausible(offsets: list[int]) -> bool:
return offsets_are_plausible(offsets, PERSPECTIVES["bass"])
def bass_tuning_name(offsets: list[int]) -> str:
return perspective_tuning_name(offsets, PERSPECTIVES["bass"])
def bass_tuning_key(offsets: list[int]) -> str:
return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
def tuning_name(offsets: list[int]) -> str:
# The pattern checks below are gated on `len(offsets)` being 6 or 4. The
# naming conventions are E-standard-rooted — e.g. a 7-string all-zeros
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
# 7+-string community content falls through to the numeric fallback (#43).
#
# Length 4 is accepted because a bass's open strings (EADG) are the low
# four of the guitar, so the same standard/drop names apply at the same
# offsets. Bass callers must normalize FIRST (`normalize_bass_offsets`):
# stored bass arrays are commonly six elements with a padded tail, and the
# padding must never reach this namer. See the block above.
# Standard tunings (all strings same offset)
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",
}
if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets):
name = standard.get(offsets[0])
if name:
return name
# Drop tunings (low string 2 semitones below the rest)
# Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D)
if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
if len(offsets) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
low_note = note_names[offsets[0] % 12]
return f"Drop {low_note}"
+7
View File
@@ -44,6 +44,13 @@ def run() -> None:
# record — including early startup messages — passes through the same
# structured pipeline.
log_config=None,
# Cap inbound WebSocket frames at the transport, before uvicorn
# materializes them in memory (its default is 16 MB). No client sends
# large frames to this server: the highway WS receives only small
# control messages, and the /ws/sync relay enforces its own tighter
# 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is
# the defense-in-depth bound above it.
ws_max_size=64 * 1024,
)
+964 -1
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,6 +14,7 @@
"devDependencies": {
"@playwright/test": "^1.59.1",
"eslint": "^9.39.4",
"eslint-plugin-import-x": "^4.17.1"
"eslint-plugin-import-x": "^4.17.1",
"tailwindcss": "^3.4.19"
}
}
+3 -2
View File
@@ -25,8 +25,8 @@
Object.freeze({
id: 'player-audio',
label: 'Player and Audio Runtime',
summary: 'Playback, renderer, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
summary: 'Playback, renderer, chart-transform, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'chart-transform', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
}),
Object.freeze({
id: 'plugin-defined',
@@ -50,6 +50,7 @@
'audio-monitoring': 'headphones',
stems: 'sliders',
'note-detection': 'activity',
'chart-transform': 'box',
diagnostics: 'fileSearch',
pipeline: 'activity',
'ui.navigation': 'list',
+12
View File
@@ -789,3 +789,15 @@
}
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
/* Tuning preference pills on gig poster */
.pp-tuning-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.5rem 0 0.3rem; }
.pp-tuning-pill { font-size: 0.65rem; padding: 0.2rem 0.6rem; border-radius: 999px; opacity: 0.7; }
.pp-tuning-pill-on { opacity: 1; border-color: #d9a253; color: #d9a253; }
.pp-tuning-select { font-size: 0.7rem; background: #1a1510; color: #c8b48a; border: 1px solid rgba(138,122,94,0.5); border-radius: 4px; padding: 0.15rem 0.4rem; }
.pp-poster-tuning-chip { font-size: 0.6rem; color: #8a7a5e; margin-left: 0.35rem; }
/* Gig interstitial — tune up banner */
.pp-gig-strip.pp-interstitial { pointer-events: auto; display: flex; align-items: center; gap: 0.75rem; border-radius: 8px; border-color: rgba(64,128,224,0.5); }
.pp-gig-tune-label { color: #4080e0; letter-spacing: 0.08em; font-size: 0.78rem; }
.pp-gig-start-btn { font-size: 0.7rem; padding: 0.25rem 0.7rem; }
+167 -20
View File
@@ -46,6 +46,8 @@ from pathlib import Path
from fastapi import Body, HTTPException
from fastapi.responses import FileResponse
import sloppak
from dlc_paths import _resolve_dlc_path
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
@@ -53,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
# arbitrary caller can ask for.
MAX_GIG_SONGS = 32
_lock = threading.Lock()
_state = {
@@ -99,6 +104,13 @@ def _bundled(venue_id):
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
def _pack_published(pack):
"""A remote pack is downloadable only once a publish has stamped its real
size the committed manifest carries a 0-byte placeholder (and an all-zero
sha) until then, so don't offer a download that can't succeed yet."""
return bool(pack and (pack.get("bytes") or 0) > 0)
def _stars():
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
@@ -517,27 +529,63 @@ def _current_venue():
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
def _fill_genre_songs(gkey, exclude, limit, tuning_ok=None):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
f"SELECT filename, title, artist, {_genre_expr(db)} AS g, tuning_name FROM songs"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
pool = [
{"filename": fn, "title": title or fn, "artist": artist or "", "tuning_name": tn or ""}
for fn, title, artist, genre, tn in rows
if _genre_key(genre) == gkey and fn not in exclude
and (tuning_ok is None or tuning_ok(tn or ""))
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _tuning_ok_fn(tuning_pref):
"""Return a (tuning_name: str) -> bool callable for the given preference.
Returns None for 'any' (no filter). 'standard' matches names ending in
' Standard'; 'drop' matches names containing 'Drop' (covers Drop D, Drop C,
Double Drop D, etc.); 'specific:<name>' matches exact names. Unknown or
malformed prefs treat as 'any' rather than hard-failing a stale client
request must not break gig booking.
"""
if not tuning_pref or tuning_pref == "any":
return None
if tuning_pref == "standard":
return lambda n: bool(n) and n.endswith(" Standard")
if tuning_pref == "drop":
return lambda n: bool(n) and "Drop" in n
if tuning_pref.startswith("specific:"):
spec = tuning_pref[len("specific:"):]
if not spec or len(spec) > 64:
return None
return lambda n, _s=spec: n == _s
return None # unknown pref → no filter
def _validate_pack_dir(pack_dir: Path):
@@ -647,7 +695,7 @@ def setup(app, context):
"unlocked": stars_total >= v["star_threshold"],
"installed": _installed(v["id"]),
"bundled": _bundled(v["id"]),
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
"download": dl,
})
return {
@@ -734,6 +782,62 @@ def setup(app, context):
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
def prepare_gig(body: dict = Body(...)):
"""Unpack every song of the set BEFORE the gig starts.
A feedpak is a zip: the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs the player
finished a number and then sat waiting for the next one to unpack, mid-
gig. A set is a known list up front, so extract it all while the player
is still looking at the poster.
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
already-unpacked dir without rewriting it. Best-effort per song one
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
raw = (body or {}).get("songs")
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs". Cap the count too — this endpoint unpacks zips,
# so an oversized list is real work, and a setlist is a handful of songs.
if not isinstance(raw, list):
return {"ok": True, "prepared": 0, "failed": []}
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
# .get, not []: a host that doesn't hand us the resolvers (or has no
# library configured) must degrade to "extract lazily, as before" — this
# is an optimisation, and it is never allowed to be the thing that stops
# a gig from starting.
get_dlc = context.get("get_dlc_dir")
get_cache = context.get("get_sloppak_cache_dir")
dlc_root = get_dlc() if callable(get_dlc) else None
cache_root = get_cache() if callable(get_cache) else None
if dlc_root is None or cache_root is None:
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
root = Path(dlc_root)
prepared, failed = 0, []
for fn in files:
# CONTAINMENT FIRST. resolve_source_dir() does a bare
# `dlc_root / filename` with no guard, so a crafted `../..` would
# walk straight out of the library. Every other filename-bound
# handler validates through _resolve_dlc_path; so does this one.
safe = _resolve_dlc_path(root, fn)
if safe is None:
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
failed.append(fn)
continue
try:
sloppak.resolve_source_dir(fn, root, Path(cache_root))
prepared += 1
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
failed.append(fn)
return {"ok": True, "prepared": prepared, "failed": failed}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
@@ -743,6 +847,8 @@ def setup(app, context):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
tuning_pref = str((body or {}).get("tuning_pref") or "any")
tuning_ok = _tuning_ok_fn(tuning_pref)
cfg = _gig_config()
try:
size = int((body or {}).get("size") or 4)
@@ -751,6 +857,18 @@ def setup(app, context):
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values())
# Annotate stubs with tuning_name (batch lookup to avoid N+1).
if stubs and _state["meta_db"] is not None:
fns = [s["filename"] for s in stubs]
ph = ",".join("?" * len(fns))
tn_rows = _state["meta_db"].conn.execute(
f"SELECT filename, tuning_name FROM songs WHERE filename IN ({ph})", fns
).fetchall()
tn_by_file = {fn: (tn or "") for fn, tn in tn_rows}
for s in stubs:
s.setdefault("tuning_name", tn_by_file.get(s["filename"], ""))
if tuning_ok is not None:
stubs = [s for s in stubs if tuning_ok(s.get("tuning_name", ""))]
req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
@@ -775,18 +893,26 @@ def setup(app, context):
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks), tuning_ok))
if not picks:
if tuning_pref and tuning_pref != "any":
label = ("standard-tuning " if tuning_pref == "standard"
else "drop-tuning " if tuning_pref == "drop"
else f"{tuning_pref[len('specific:'):]}”-tuning "
if tuning_pref.startswith("specific:") else "")
raise HTTPException(404, f"No {label}songs of this genre in the library.")
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
return {
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"tuning_pref": tuning_pref,
"venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or ""} for s in picks[:size]],
"artist": s.get("artist") or "", "tuning_name": s.get("tuning_name") or ""}
for s in picks[:size]],
}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
@@ -855,13 +981,34 @@ def setup(app, context):
_save_json(_state_file(), st)
return {"ok": True, "gig": gig}
@app.get(f"/api/plugins/{PLUGIN_ID}/gigs/tunings")
def gig_tunings(genre: str = ""):
"""Distinct tuning names present in a genre's song pool, for the
specific-tuning picker in the gig poster UI. Sorted by the library's
own tuning_sort_key so the list matches the main library tuning filter."""
gkey = _genre_key(_genre_display(genre)) if genre else ""
if not gkey:
return {"tunings": []}
db = _state["meta_db"]
if db is None:
return {"tunings": []}
rows = db.conn.execute(
f"SELECT tuning_name, tuning_sort_key, {_genre_expr(db)} AS g "
"FROM songs WHERE tuning_name != ''"
).fetchall()
seen: dict[str, int] = {}
for tn, sk, genre_raw in rows:
if _genre_key(genre_raw) == gkey and tn and tn not in seen:
seen[tn] = sk or 0
return {"tunings": sorted(seen.keys(), key=lambda n: (seen[n], n))}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
if venue is None:
raise HTTPException(404, "Unknown venue.")
pack = venue.get("pack")
if not pack:
if not _pack_published(pack):
raise HTTPException(404, "No pack published for this venue yet.")
stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
+211 -9
View File
@@ -12,6 +12,10 @@
'use strict';
const API = '/api/plugins/career';
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
// so this is generous — but it is a CEILING, not a wait. Past it we start the
// gig and let the first play extract lazily, as it always did.
const PREPARE_TIMEOUT_MS = 60000;
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
const NO_VENUE = '__none__';
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
@@ -22,6 +26,7 @@
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
const PP_INST_KEY = 'feedBack-career-instrument';
const PP_TAB_KEY = 'feedBack-career-tab';
const PP_TUNING_PREF_KEY = 'feedBack-career-tuning-pref';
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
@@ -39,7 +44,12 @@
let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending)
let _ppGigProposal = null; // the booking poster's proposal, while open
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, tuning_pref, idx} mid-set
let _ppGigTuningPref = 'any'; // loaded from localStorage in boot()
let _ppGigTuningNames = []; // cached distinct tuning names for the specific picker
let _ppGigTuningHold = null; // holdAutoplay release fn — non-null = interstitial active
let _ppGigLastTuning = null; // tuning_name of the current gig song (for change detection)
let _ppBookGen = 0; // generation counter — stale responses are discarded
function $(id) { return document.getElementById(id); }
@@ -770,6 +780,7 @@
function closeBook() {
_ppBook = null;
_ppGigProposal = null; // a dismissed poster is a dismissed booking
++_ppBookGen; // invalidate any in-flight bookGig request
const overlay = $('pp-overlay');
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
@@ -1081,13 +1092,25 @@
function gigPosterHTML(prop) {
const bill = prop.songs.map((s, i) =>
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}</div>`).join('');
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}${s.tuning_name ? ` <small class="pp-poster-tuning-chip">${esc(s.tuning_name)}</small>` : ''}</div>`).join('');
const PREF_LABELS = { any: 'Any', standard: 'Standard', drop: 'Drop', specific: 'Specific…' };
const isSpecific = _ppGigTuningPref.startsWith('specific:');
const curPill = isSpecific ? 'specific' : _ppGigTuningPref;
const pills = Object.keys(PREF_LABELS).map((p) =>
`<button data-pp-tuning="${esc(p)}" class="career-btn career-btn-ghost pp-tuning-pill${curPill === p ? ' pp-tuning-pill-on' : ''}">${PREF_LABELS[p]}</button>`
).join('');
const selVal = isSpecific ? _ppGigTuningPref.slice('specific:'.length) : '';
const selOpts = _ppGigTuningNames.length
? `<option value="">Choose tuning…</option>${_ppGigTuningNames.map((n) => `<option value="${esc(n)}"${n === selVal ? ' selected' : ''}>${esc(n)}</option>`).join('')}`
: `<option value="">Loading…</option>`;
const selHidden = isSpecific ? '' : ' hidden';
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
<div class="pp-poster">
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
<div class="pp-poster-presents">presents</div>
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
<div class="pp-tuning-row">${pills}<select data-pp-tuning-select class="pp-tuning-select${selHidden}">${selOpts}</select></div>
<div class="pp-poster-bill">${bill}</div>
<div class="pp-poster-actions">
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
@@ -1106,15 +1129,38 @@
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
.find((x) => x.genre_key === gkey);
if (!p) return;
const gen = ++_ppBookGen; // F1: capture generation before await — stale responses discarded
try {
const res = await fetch(`${API}/gigs/propose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument: inst, genre: p.genre }),
body: JSON.stringify({ instrument: inst, genre: p.genre, tuning_pref: _ppGigTuningPref }),
});
if (!res.ok) return;
if (gen !== _ppBookGen) return; // stale response — a newer request supersedes this one
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (gen !== _ppBookGen) return; // stale — superseded while awaiting error json()
if (res.status === 404) {
// No-match 404: revert tuning pref to 'any', re-render poster to match
_ppGigTuningPref = 'any';
lsSet(PP_TUNING_PREF_KEY, 'any');
if (_ppGigProposal) {
const overlay = $('pp-overlay');
if (overlay) overlay.innerHTML = gigPosterHTML(_ppGigProposal);
}
}
// All errors: notify (404 has a tuning-specific message; others are generic)
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
const msg = res.status === 404
? (err && err.detail) || 'No songs match that tuning filter.'
: 'Could not book gig — please try again.';
try { window.fbNotify.show({ title: 'Gig booking', message: msg, icon: '🎸' }); } catch (_) { /* */ }
}
return;
}
_ppGigProposal = await res.json();
} catch (_) { return; }
if (gen !== _ppBookGen) return; // stale — superseded while awaiting json()
const overlay = $('pp-overlay');
if (!overlay) return;
_ppBook = null; // the poster replaces the book in the overlay
@@ -1123,10 +1169,72 @@
sfx('page');
}
function startGig() {
async function _openSpecificTuningPicker() {
const overlay = $('pp-overlay');
if (!overlay || !_ppGigProposal) return;
if (_ppGigTuningNames.length) {
const sel = overlay.querySelector('[data-pp-tuning-select]');
if (sel) sel.classList.remove('hidden');
return;
}
try {
const res = await fetch(`${API}/gigs/tunings?genre=${encodeURIComponent(_ppGigProposal.genre)}`);
if (!res.ok) return;
const data = await res.json();
_ppGigTuningNames = Array.isArray(data.tunings) ? data.tunings : [];
} catch (_) { return; }
// Re-render with populated options
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
const sel = overlay.querySelector('[data-pp-tuning-select]');
if (sel) sel.classList.remove('hidden');
}
// Unpack the whole set before the first note.
//
// A feedpak is a zip, and the first play of one pays for its extraction. In
// a set that cost landed BETWEEN songs: the player finished a number and
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
// known up front, so warm it all while the poster is still on screen.
//
// Best-effort by design: a library that won't pre-extract must not stop the
// gig from starting — the play itself surfaces the error the same way it
// does outside a gig. Slow is better than blocked.
async function prepareGigSongs(prop, btn) {
const label = btn && btn.textContent;
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
// A bare `await fetch(...)` only rejects on a network ERROR — a server
// that accepts the connection and then never answers hangs forever, and
// the gig would never start. That would make this optimisation the very
// thing it promises never to be: the reason you cannot play. Give up
// waiting and let the first play extract lazily, exactly as before.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
try {
await fetch(`${API}/gigs/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
signal: ctrl.signal,
});
} catch (_) {
// abort, offline, non-2xx — all the same: start the gig anyway.
} finally {
clearTimeout(timer);
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
}
}
async function startGig(btn) {
const prop = _ppGigProposal;
const q = window.feedBack && window.feedBack.playQueue;
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
// so a failure here leaves nothing half-applied to unwind.
await prepareGigSongs(prop, btn);
// The poster's Play could have been cancelled while we were unpacking.
if (_ppGigProposal !== prop) return;
// The gig BORROWS the stage: stash whatever venue/viz the user had so
// the set ending gives it back (unlike "Play here", which is an
// explicit persistent choice on the venue card).
@@ -1146,16 +1254,32 @@
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* viz optional — restore stays intact */ }
}
// Push the gig's venue pack to the crowd layer NOW.
//
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
// and pushCrowdManifest is called only from refresh() — the career
// tab's own reload. A gig navigates AWAY from the career tab to the
// player, so refresh() never runs during it, and setting the override
// above does nothing on its own. The result the testers saw: the venue
// visualization turns on (3D highway) but its crowd/stage pack never
// loads, so the song plays over the bare highway backdrop ("standard
// particles"), or over whatever venue a previous refresh() happened to
// leave applied. We just changed the override to this gig's venue, so
// re-push for it. _state is the career state the booking screen already
// fetched; guard for the rare null.
_appliedManifestVenue = null;
if (_state) pushCrowdManifest(_state);
_ppGigRun = {
songs: prop.songs,
venue_id: prop.venue_id,
genre: prop.genre,
genre_key: prop.genre_key,
instrument: prop.instrument,
tuning_pref: prop.tuning_pref || 'any',
idx: 0,
restore,
};
_ppGigLastTuning = null; // reset for fresh interstitial tracking
closeBook();
_ppGigProposal = null;
// RAW filenames: the queue itself encodes for playSong — pre-encoding
@@ -1191,8 +1315,14 @@
document.body.appendChild(strip);
}
const run = _ppGigRun;
const next = run.songs[run.idx + 1];
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
if (_ppGigTuningHold) {
const song = run.songs[run.idx];
const tuning = (song && song.tuning_name) ? esc(song.tuning_name) : 'check tuning';
strip.innerHTML = `<b class="pp-gig-tune-label">Tune to: ${tuning}</b><button data-pp-gig-start-song="1" class="career-btn career-btn-primary pp-gig-start-btn">Start song</button>`;
} else {
const next = run.songs[run.idx + 1];
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
}
}
function removeGigStrip() {
@@ -1204,6 +1334,8 @@
// No fail state: an abandoned set logs nothing and says nothing.
const run = _ppGigRun;
_ppGigRun = null;
_ppGigLastTuning = null;
if (_ppGigTuningHold) { const h = _ppGigTuningHold; _ppGigTuningHold = null; h(); }
removeGigStrip();
restoreGigStage(run);
}
@@ -1343,6 +1475,32 @@
// Queue lifecycle: advance the strip per song; complete or abandon.
function onGigSongLoading() {
if (!_ppGigRun) return;
const run = _ppGigRun;
const song = run.songs[run.idx];
const tuningName = (song && song.tuning_name) || '';
const pref = run.tuning_pref || 'any';
// Interstitial: pause before first song (or when tuning changes) for all
// non-specific prefs, so the player has time to retune. "specific" is
// excluded because every song already matches one fixed tuning.
const needsInterstitial = !pref.startsWith('specific:') && (
run.idx === 0 || tuningName !== _ppGigLastTuning
);
_ppGigLastTuning = tuningName;
if (needsInterstitial) {
const fb = window.feedBack;
const holdFn = fb && typeof fb.holdAutoplay === 'function' ? fb.holdAutoplay : null;
_ppGigTuningHold = holdFn ? holdFn() : null;
if (_ppGigTuningHold) {
// Cancel the fail-open backstop — we manage the dismiss ourselves
// (user clicks "Start song"). A song navigation clears the hold anyway.
_ppGigTuningHold.settle();
}
if (_ppGigTuningHold && window.tuner && typeof window.tuner.enable === 'function') {
window.tuner.enable({ auto: true }).catch(() => {});
}
} else {
_ppGigTuningHold = null;
}
renderGigStrip();
}
@@ -1422,9 +1580,31 @@
closeBook();
return;
}
// Tuning pref pill on the gig poster
const tuningPill = e.target.closest('[data-pp-tuning]');
if (tuningPill) {
const pref = tuningPill.dataset.ppTuning;
if (pref === 'specific') {
_openSpecificTuningPicker();
} else {
_ppGigTuningPref = pref;
lsSet(PP_TUNING_PREF_KEY, pref);
_ppGigTuningNames = []; // reset specific cache on pref change
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
}
return;
}
// "Start song" interstitial button (mid-gig tuning pause)
if (e.target.closest('[data-pp-gig-start-song]') && _ppGigTuningHold) {
const release = _ppGigTuningHold;
_ppGigTuningHold = null;
renderGigStrip();
release();
return;
}
const gigBtn = e.target.closest('[data-pp-gig]');
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
if (e.target.closest('[data-pp-gig-reroll]')) {
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
return;
@@ -1479,11 +1659,23 @@
}
function boot() {
// Restore persisted tuning preference
_ppGigTuningPref = lsGet(PP_TUNING_PREF_KEY) || 'any';
const screen = document.getElementById('plugin-career');
if (screen) {
screen.addEventListener('click', onClick);
screen.addEventListener('pointermove', onTiltMove);
screen.addEventListener('pointerleave', onTiltLeave);
// Specific-tuning select change: rebook with the chosen tuning
screen.addEventListener('change', (e) => {
const sel = e.target.closest('[data-pp-tuning-select]');
if (!sel || !_ppGigProposal) return;
const val = sel.value;
if (!val) return;
_ppGigTuningPref = 'specific:' + val;
lsSet(PP_TUNING_PREF_KEY, _ppGigTuningPref);
bookGig(_ppGigProposal.genre_key);
});
}
const sm = window.feedBack;
if (sm && typeof sm.on === 'function') {
@@ -1516,10 +1708,20 @@
window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
onGigSongEnded, onGigSongStop,
onGigSongEnded, onGigSongStop, onGigSongLoading,
setGigRun(r) { _ppGigRun = r; },
getGigRun() { return _ppGigRun; },
setView(v) { _pp = v; },
getTuningHold() { return _ppGigTuningHold; },
setTuningHold(h) { _ppGigTuningHold = h; },
getTuningPref() { return _ppGigTuningPref; },
setTuningPref(p) { _ppGigTuningPref = p; },
getLastTuning() { return _ppGigLastTuning; },
setLastTuning(t) { _ppGigLastTuning = t; },
getBookGen() { return _ppBookGen; },
setBookGen(g) { _ppBookGen = g; },
getProposal() { return _ppGigProposal; },
bookGig, closeBook,
};
if (document.readyState === 'loading') {
+10 -2
View File
@@ -17,14 +17,22 @@
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
"star_threshold": 50,
"pack": null
"pack": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 0
}
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
"star_threshold": 150,
"pack": null
"pack": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
"bytes": 351284599
}
}
]
}
+1 -1
View File
@@ -155,7 +155,7 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
- `getNoteState(note, chartTime)` — feedBack#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'``mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'``mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
`tuning` and `capo` aren't consumed by this plugin.
`tuning` and `capo` feed only the nut's open-string pitch labels. They prefer the bundle's effective values; `songInfo` remains the original metadata fallback. Note placement never reads them.
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
+18 -10
View File
@@ -1,12 +1,20 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.31.5",
"type": "visualization",
"bundled": true,
"script": "screen.js",
"styles": "assets/plugin.css",
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"routes": "routes.py",
"tour": "tour.json"
"id": "highway_3d",
"name": "3D Highway",
"version": "3.54.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
"script": "screen.js",
"styles": "assets/plugin.css",
"settings": {
"html": "settings.html",
"category": "graphics",
"server_files": [
"plugin_uploads/highway_3d/current.mp4",
"plugin_uploads/highway_3d/current.webm"
]
},
"routes": "routes.py",
"tour": "tour.json"
}
+1061 -10985
View File
File diff suppressed because it is too large Load Diff
+850
View File
@@ -0,0 +1,850 @@
// h3d-carve-12: T-section (arpeggio inference) extracted from screen.js.
// VERBATIM-MOVE: all function bodies are byte-for-byte identical to their
// screen.js originals except for the single DI rewire noted below.
// No logic changes, no new guards, no structural additions.
//
// Beyond-subst changes:
// 1. arpeggioLaneDividerXYScaleMatchFrameRim: `nStr` → `getNStr()`
// (the explicit argument `sY(nStr - 1)` — sY itself is a plain shorthand
// that already captures live state internally, but the argument needs getNStr())
// 2. _resetStringDependentCaches: STAYS in screen.js; this module exports
// `resetChordShapeCache()` instead, which screen.js calls to reset
// _chordShapeCache (the only cache that moved here).
//
// lowerBoundT imported directly from ./geometry.js — not in DI surface.
// Total DI params: 19 (18 plain const shorthand + 1 live getter).
// Missed in contract survey (corrected before GO): NEXT_ON_STRING_T_EPS (line 248).
import { lowerBoundT } from './geometry.js';
export function createArp({
// ── plain const shorthand (fn refs or number consts, never reassigned) ──
validString, // IIFE fn decl ~line 3736
filterValidNotes, // IIFE fn decl ~line 3767 — used by arpeggioLaneDividerFrameAccentMul
sY, // factory-scope fn: s => S_BASE + (...) * S_GAP (captures live vars)
K, // module-level const line 124
S_GAP, // module-level const line 203
BEHIND, // module-level const line 206
CHORD_FRAME_RIM_MIN, // line 610
CHORD_FRAME_RIM_FRAC_H, // line 611
ARP_FRAME_ONSET_PAD_S, // line 620
ARP_FRAME_ONSET_CLUSTER_S, // line 621
ARP_INFER_MIN_HAND_SHAPE_SPAN_S, // line 628
ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S, // line 634
ARP_INFER_MULTI_STRUM_HIT_SLACK, // line 640
ARP_INFER_MULTI_STRUM_WIN_MIN_S, // line 642
ARP_INFER_MIN_HITS_VS_SHAPE_CAP, // line 653
ARP_HWY_RAIL_END_TAIL_S, // line 263
ARP_HWY_RAIL_START_LEAD_S, // line 265
NEXT_ON_STRING_T_EPS, // line 248 — used in chordShapeCoveredByStandaloneNotes
// ── live getter (let var, reassigned per arrangement/frame) ──
getNStr, // () => nStr — used in arpeggioLaneDividerXYScaleMatchFrameRim
}) {
// ── Pre-arp utilities ─────────────────────────────────────────────
function truthyChartFlag(v) {
if (v === true || v === 1) return true;
if (v === '1') return true;
return typeof v === 'string' && v.toLowerCase() === 'true';
}
/** RS / sloppak `hd` (highDensity); tolerate occasional string forms. */
function chordWireHighDensity(ch) {
return truthyChartFlag(ch && ch.hd);
}
/**
* Per spec, `displayName` is the UI label for a chord template
* (defaulting to `name` when the chart didn't set it). Always go
* through this helper so name vs. displayName drift can't surface
* the wrong label or break displayName-based dedupe heuristics.
*/
function chordTemplateLabel(tmpl) {
if (!tmpl) return '';
const d = tmpl.displayName;
if (typeof d === 'string' && d.length > 0) return d;
const n = tmpl.name;
return typeof n === 'string' ? n : '';
}
/**
* Arpeggio styling is driven by authored metadata, not by post-hoc
* note-stream inference. Prefer explicit hand-shape flags and fall back
* to template markers when present.
*/
function chordTemplateMarkedArpeggio(cid, chordTemplates) {
if (cid == null || !chordTemplates) return false;
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
if (!tmpl) return false;
if (truthyChartFlag(tmpl.arp) || truthyChartFlag(tmpl.arpeggio)) return true;
const displayName = typeof tmpl.displayName === 'string' ? tmpl.displayName.toLowerCase() : '';
if (displayName.includes('-arp')) return true;
const name = typeof tmpl.name === 'string' ? tmpl.name.toLowerCase() : '';
return name.endsWith('(arp)') || name.includes(' arpeggio');
}
function handShapeMarkedArpeggio(hs, chordTemplates) {
if (!hs) return false;
if (truthyChartFlag(hs.arp) || truthyChartFlag(hs.arpeggio)) return true;
return chordTemplateMarkedArpeggio(hsChordIdNorm(hs), chordTemplates);
}
// ── Hint cache ───────────────────────────────────────────────────
/**
* Matching hand-shape metadata for a chord onset. ``explicit`` follows
* authored arpeggio markers only; note inference is handled separately
* by the callers that still need it for non-visual behavior.
*
* Cached per chord: result depends only on (ch, hss, chordTemplates),
* all chart-static for the lifetime of an arrangement. The cache is
* swapped on (hss, templates) ref change so an arrangement switch
* cannot resurrect stale entries. Empty-input case bypasses the cache
* it returns a fresh sentinel anyway and isn't hot enough to share.
*/
const _HINT_NONE = Object.freeze({ explicit: false, covered: false, hs: null });
let _hintCache = new WeakMap();
let _hintCacheHsRef = null;
let _hintCacheTplRef = null;
function chordHandShapeArpeggioHint(ch, hss, chordTemplates) {
if (!hss || hss.length === 0) return _HINT_NONE;
if (_hintCacheHsRef !== hss || _hintCacheTplRef !== chordTemplates) {
_hintCache = new WeakMap();
_hintCacheHsRef = hss;
_hintCacheTplRef = chordTemplates;
}
const cached = _hintCache.get(ch);
if (cached !== undefined) return cached;
const t = ch.t;
const cid = ch.id;
let result = _HINT_NONE;
for (let i = 0; i < hss.length; i++) {
const hs = hss[i];
const tLo = hsStart(hs);
const tHi = hsEnd(hs);
if (Number.isNaN(tLo) || Number.isNaN(tHi)) continue;
if (t + 1e-4 < tLo || t > tHi + 1e-4) continue;
const hsCid = hsChordIdNorm(hs);
if (hsCid !== cid && Number(hsCid) !== Number(cid)) continue;
const explicit = handShapeMarkedArpeggio(hs, chordTemplates);
result = { explicit, covered: true, hs };
break;
}
_hintCache.set(ch, result);
return result;
}
/** Build ``ch.notes`` from ``chordTemplates[cid].frets`` (-1 omitted). */
function chordNotesFromTemplate(cid, templates) {
if (templates == null || cid == null) return [];
const tmpl = templates[cid] ?? templates[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) return [];
const out = [];
for (let si = 0; si < tmpl.frets.length; si++) {
const f = tmpl.frets[si];
if (f >= 0 && validString(si)) out.push({ s: si, f, sus: 0 });
}
return out;
}
/**
* Chart-format fingerpicking passages often have ``<handShape>`` + per-string
* ``<note>`` rows but **no** ``<chord>`` events. The 3D chord frame / arp
* styling only runs over ``bundle.chords``, so synthesize minimal chord
* rows at each hand-shape onset when the chart omits them.
*/
function mergeHandShapeSynthChords(realChords, handShapes, chordTemplates) {
if (!handShapes || handShapes.length === 0) return realChords;
const reals = realChords && realChords.length ? realChords : [];
const synth = [];
const seenSynth = new Set();
const tol = 0.028;
/**
* Suppress a synth chord box when a real chord with the **same trimmed
* display name** played within this window Custom songs commonly authors
* several ``<chordTemplate>`` rows that share a display name (with
* trailing-whitespace IDs) for fingering variants. The follow-up
* hand-shape with no chord row is a fingering hint, not a new strum
* (e.g. Jackson 5 "I Want You Back" ~0:27 Fm7 cid=18 strum followed
* by Fm7 cid=19 hand-shape, which earlier produced a stacked second
* "Fm7" label and an extra chord frame).
*/
const SAME_NAME_RUN_S = 0.5;
const trimmedTemplateName = (cid) => {
if (cid == null || !chordTemplates) return '';
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
// custom songs commonly authors several <chordTemplate> rows that share
// a displayName for fingering variants; the suppression
// heuristic in the surrounding code dedupes on the *label*,
// not the underlying name, so go through chordTemplateLabel.
return chordTemplateLabel(tmpl).trim();
};
outer: for (let i = 0; i < handShapes.length; i++) {
const hs = handShapes[i];
const cid = hs.chord_id != null ? hs.chord_id : hs.chordId;
const st = hs.start_time != null ? hs.start_time : hs.startTime;
if (cid == null || st == null || Number.isNaN(Number(st))) continue;
const key = `${cid}|${Number(st).toFixed(3)}`;
if (seenSynth.has(key)) continue;
seenSynth.add(key);
const myName = trimmedTemplateName(cid);
for (let j = 0; j < reals.length; j++) {
const ch = reals[j];
const rid = ch.id;
const sameId = rid === cid || Number(rid) === Number(cid);
if (sameId && Math.abs(ch.t - st) <= tol) continue outer;
// A real strum at the same onset already represents this
// chord — never synthesize a phantom on top of it. The
// id/name checks alone miss hand-shapes whose template
// differs from (or shares no name with) the coincident real
// chord — e.g. an edited chart that left a stale hand-shape
// template pointing at the pre-edit shape, which then drew a
// spurious second power chord beside the real one.
if (Math.abs(ch.t - st) <= tol) continue outer;
if (!sameId && myName !== '') {
const otherName = trimmedTemplateName(rid);
if (otherName === myName
&& st > ch.t
&& st - ch.t <= SAME_NAME_RUN_S) {
continue outer;
}
}
}
const notes = chordNotesFromTemplate(cid, chordTemplates);
if (notes.length === 0) continue;
const et = hs.end_time != null ? hs.end_time : hs.endTime;
synth.push({
t: st,
id: cid,
// `hd` is the chart-format `highDensity` wire field (gallops /
// repeated strums), not an arpeggio carrier — arpeggio
// intent is read directly from the hand-shape via
// chordHandShapeArpeggioHint() downstream. Keep `hd` false
// so chordWireHighDensity() / label-suppression behave the
// same as for any other non-gallop chord row.
hd: false,
notes,
/** Hand-shape fill-in (no authored chord row) — skip note-stream arp frame. */
h3dSynth: true,
/** Hand-shape end time — used to draw the shape-sustain border for non-arp cases. */
h3dSynthEnd: et != null ? Number(et) : null,
});
}
if (synth.length === 0) return reals;
const merged = reals.concat(synth);
merged.sort((a, b) => {
const dt = a.t - b.t;
if (Math.abs(dt) > 1e-6) return dt;
const ia = Number(a.id);
const ib = Number(b.id);
return (ia - ib) || 0;
});
return merged;
}
// ── Chord-shape cache ─────────────────────────────────────────────
/**
* Merge chart-format ``chordTemplates[id].frets`` with live ``chordNote`` rows.
* Cached via WeakMap on the chord object chord data never changes after
* chart load, so the Map is computed once and reused every frame.
* The init-time callers (fillArpeggioGhostInferFlags) pass ephemeral `fakeCh`
* objects that are never seen again, so they bypass the cache naturally.
*/
let _chordShapeCache = new WeakMap();
function mergeChordShape(ch, chordNotes, templates) {
if (_chordShapeCache.has(ch)) return _chordShapeCache.get(ch);
const shape = new Map();
const tid = ch && ch.id != null ? ch.id : null;
const tmpl = (tid != null && templates)
? (templates[tid] ?? templates[Number(tid)])
: null;
if (tmpl && Array.isArray(tmpl.frets)) {
for (let si = 0; si < tmpl.frets.length; si++) {
if (!validString(si)) continue;
const f = tmpl.frets[si];
if (f >= 0) shape.set(si, f);
}
}
for (let i = 0; i < chordNotes.length; i++) {
const cn = chordNotes[i];
if (!validString(cn.s)) continue;
if (cn.f < 0) shape.delete(cn.s);
else shape.set(cn.s, cn.f);
}
_chordShapeCache.set(ch, shape);
return shape;
}
// h3d-carve-12: screen.js's _resetStringDependentCaches() calls this
// instead of directly assigning `_chordShapeCache = new WeakMap()`.
// Reset the validString()/nStr-dependent chord caches. Called when nStr
// changes so a string count discovered after the first frame (e.g. a
// 7-string chart whose stringCount arrives in song_info) doesn't leave
// string-6+ notes filtered out of cached chord shapes/signatures.
function resetChordShapeCache() {
_chordShapeCache = new WeakMap();
}
function hitTimesQualifyArpeggioSpread(hitTimes) {
if (hitTimes.length < 2) return false;
hitTimes.sort((a, b) => a - b);
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
if (spread >= 0.03) return true;
return hitTimes.length >= 4 && spread >= 0.016;
}
/** RS XML / IPC payloads use snake_case or camelCase field names. */
function hsStart(hs) {
if (!hs) return NaN;
const v = hs.start_time != null ? hs.start_time : hs.startTime;
if (v == null) return NaN;
const n = Number(v);
return Number.isNaN(n) ? NaN : n;
}
function hsEnd(hs) {
if (!hs) return NaN;
const v = hs.end_time != null ? hs.end_time : hs.endTime;
if (v == null) return NaN;
const n = Number(v);
return Number.isNaN(n) ? NaN : n;
}
function hsChordIdNorm(hs) {
if (!hs) return null;
const v = hs.chord_id != null ? hs.chord_id : hs.chordId;
return v == null ? null : v;
}
/** ``<handShape>`` chart duration in seconds (snake_case or camelCase XML). */
function handShapeChartSpanSec(hs) {
const a = hsStart(hs), b = hsEnd(hs);
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
return Math.max(0, b - a);
}
// ── Infer-pattern cache ───────────────────────────────────────────
/**
* When ``hd`` is missing/false, detect arpeggio from the **note** stream
* using the **full voicing** (template chord notes). RS often stores the
* plucks only in ``notes[]``, not as duplicate chord rows.
*
* @param {{ tLo: number, tHi: number } | null} [timeWin]
* When set (e.g. from ``<handShape>`` span), scan staggered picks
* across the whole held-shape window RS often omits ``arp`` and ``hd``.
*/
// Cached per chord: result depends on (ch, shape, notesArr) and an
// optional timeWin which itself is a function of the chord's matching
// <handShape>. Both inputs are chart-static, so the cache invalidates
// on (notesArr, hss) ref change — `hss` is threaded in purely as the
// invalidation key for the chord-loop caller, which passes a stable
// `ch` (reused across frames) and a timeWin that is null until
// bundle.handShapes arrives over the WS; without the hss check the
// null-timeWin result would stick once handShapes loaded late. shape
// comes from mergeChordShape(ch) which is also chart-static, so it
// doesn't enter the invalidation key directly. The cache deliberately
// stores boolean results; a sentinel distinguishes "not computed"
// from "false".
let _arpInferCache = new WeakMap();
let _arpInferCacheNotesRef = null;
let _arpInferCacheHssRef = null;
function inferArpeggioFromNotePattern(ch, shape, notesArr, timeWin, hss = null) {
if (!notesArr || notesArr.length === 0 || shape.size < 2) return false;
if (_arpInferCacheNotesRef !== notesArr || _arpInferCacheHssRef !== hss) {
_arpInferCache = new WeakMap();
_arpInferCacheNotesRef = notesArr;
_arpInferCacheHssRef = hss;
}
const cached = _arpInferCache.get(ch);
if (cached !== undefined) return cached;
const result = _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin);
_arpInferCache.set(ch, result);
return result;
}
function _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin) {
const tHi = timeWin ? timeWin.tHi : ch.t + 2.35;
const tLo = timeWin ? timeWin.tLo : ch.t - 0.28;
let i2 = lowerBoundT(notesArr, tLo - 0.02);
const hitTimes = [];
const hitStrings = new Set();
for (; i2 < notesArr.length; i2++) {
const n = notesArr[i2];
if (n.t > tHi) break;
if (n.t < tLo) continue;
if (!validString(n.s)) continue;
const ef = shape.get(n.s);
if (ef === undefined || ef !== n.f) continue;
hitTimes.push(n.t);
hitStrings.add(n.s);
}
if (!hitTimesQualifyArpeggioSpread(hitTimes)) return false;
// A genuine arpeggio SWEEPS across the held shape, so its standalone
// notes land on MULTIPLE strings of the shape. When every matching
// hit is on a single string, this is a repeated single-string run
// (e.g. a palm-muted gallop hammering the chord's root) that happens
// to share one string/fret with the chord — NOT an arpeggio. Inferring
// one here deferred the chord's gems and made the power chord render as
// just that one repeated note (bar 25 of starlight). Require ≥2 strings.
if (hitStrings.size < 2) return false;
// Strumming/gallop rejection — far more hits than the shape has
// strings means the chord's notes are being re-struck repeatedly
// (a riff/gallop reusing both power-chord notes), not swept once as
// an arpeggio. This guard used to live inside `if (timeWin)`, so it
// was skipped for charts with no hand-shapes (timeWin null) — which
// let dense two-string gallops over a power chord infer a bogus
// arpeggio and defer the chord's gems (bar 88 of starlight: a
// (s5:4,s6:2) chord whose root+fifth recur ~16x over 2 s). Apply it
// with the actual window span whether or not a hand-shape is present.
const winSpan = timeWin ? (timeWin.tHi - timeWin.tLo) : (tHi - tLo);
if (winSpan > ARP_INFER_MULTI_STRUM_WIN_MIN_S
&& hitTimes.length > shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACK) {
return false;
}
if (timeWin) {
if (winSpan < 0.70 && hitTimes.length < 4) {
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
if (spread < ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S) return false;
}
// Reject when too few staggered hits for a genuine sweep across
// the held shape — see ARP_INFER_MIN_HITS_VS_SHAPE_CAP.
const minHits = Math.min(shape.size, ARP_INFER_MIN_HITS_VS_SHAPE_CAP);
if (hitTimes.length < minHits) return false;
}
return true;
}
/**
* True when standalone note rows already cover every string/fret in the
* arpeggio shape, so drawing the chord gems too would duplicate the same
* authored passage.
*/
// Cached per chord: result depends on (ch, shape, notesArr) — chart-
// static; the cache invalidates on notesArr ref change. The same
// ``ch`` may be queried multiple times per frame from the chord
// render loop (deferChordGems / _deferFallback / suppressSynthChord),
// so survival across frames is also useful.
let _arpCoverCache = new WeakMap();
let _arpCoverCacheNotesRef = null;
function chordShapeCoveredByStandaloneNotes(ch, shape, notesArr, timeWin) {
if (!notesArr || notesArr.length === 0 || !shape || shape.size === 0) return false;
if (_arpCoverCacheNotesRef !== notesArr) {
_arpCoverCache = new WeakMap();
_arpCoverCacheNotesRef = notesArr;
}
const cached = _arpCoverCache.get(ch);
if (cached !== undefined) return cached;
const tLo = (timeWin ? timeWin.tLo : ch.t - ARP_FRAME_ONSET_PAD_S) - NEXT_ON_STRING_T_EPS;
const tHi = (timeWin ? timeWin.tHi : ch.t + ARP_FRAME_ONSET_CLUSTER_S) + NEXT_ON_STRING_T_EPS;
let i2 = lowerBoundT(notesArr, tLo);
const matchedStrings = new Set();
let result = false;
for (; i2 < notesArr.length; i2++) {
const n = notesArr[i2];
if (n.t > tHi) break;
if (!validString(n.s) || matchedStrings.has(n.s)) continue;
const ef = shape.get(n.s);
if (ef === undefined || ef !== n.f) continue;
matchedStrings.add(n.s);
if (matchedStrings.size >= shape.size) { result = true; break; }
}
_arpCoverCache.set(ch, result);
return result;
}
/**
* Notes in an inferred arpeggio passage are charted in ``notes[]`` with
* staggered times; treat them like chord-cluster notes for chart-format-style
* board-ghost fret digits (``fromChord`` + template column).
*/
function arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr) {
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0) return null;
if (!validString(n.s)) return null;
for (let i = 0; i < handShapes.length; i++) {
const hs = handShapes[i];
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
const cid = hsChordIdNorm(hs);
if (cid == null) continue;
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
if (synthNotes.length === 0) continue;
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) continue;
if (inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes)) return cid;
}
return null;
}
/**
* Per-frame warmup: ``inferArpeggioFromNotePattern`` depends only on
* ``handShape × chart``, not on the candidate note the old path
* recomputed it for every visible note (O(notecount × hs × notescan)).
* Fill ``outFlags[i]`` with the boolean once per ``handShapes[i]``.
*/
function fillArpeggioGhostInferFlags(handShapes, chordTemplates, notesArr, outFlags, outSynthOnsetSet = null) {
for (let i = 0; i < handShapes.length; i++) {
let infer = false;
const hs = handShapes[i];
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) {
outFlags[i] = false;
continue;
}
const cid = hsChordIdNorm(hs);
if (cid != null && notesArr.length > 0) {
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (tmpl && Array.isArray(tmpl.frets)) {
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
if (synthNotes.length > 0) {
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
infer = inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes);
// Chord-hold gate: inferArpeggioFromNotePattern can fire true
// when open-string notes coincidentally match the template's
// open positions but only a SINGLE fretted (f>0) string is
// actually played at the handshape onset. Treat that as a
// chord hold (not an arpeggio) — clear the arp flag, no
// brackets. The original implementation also intended to
// record a synthetic sustain extending to hsEnd for the
// onset note, but that read-side was never wired up; the
// visual decay-before-handshape-end is benign.
if (infer) {
let _frettedCount = 0;
let _onsetNote = null;
const _fSeen = new Set();
let _ci = lowerBoundT(notesArr, tw.tLo - 0.02);
for (; _ci < notesArr.length; _ci++) {
const _cn = notesArr[_ci];
if (_cn.t > tw.tHi + 0.02) break;
if (_cn.t < tw.tLo) continue;
if (!validString(_cn.s)) continue;
if (shape.get(_cn.s) !== _cn.f) continue;
if (_cn.f > 0 && !_fSeen.has(_cn.s)) {
_frettedCount++;
_fSeen.add(_cn.s);
if (_onsetNote === null) _onsetNote = _cn;
}
}
if (_frettedCount <= 1 && _onsetNote !== null) {
outFlags[i] = false;
continue; // chord hold handled — skip onset-match and outFlags assignment
}
}
// Non-arp template inferred as arpeggio: suppress brackets.
// Only explicit arp-marked templates (arp:true / displayName "-arp")
// should show [ ] / < > bracket markers.
if (infer && outSynthOnsetSet != null
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
outSynthOnsetSet.add(hsLo);
}
// Also treat as arp ghost when the hs generated a suppressed
// synth chord: any standalone note in the onset window matches
// any shape string. Handles patterns where inferArpeggioFromNotePattern
// returns false (e.g. repeated arpeggio across a long hs span
// triggers the multi-strum rejection), but the player still
// needs the "hold this shape" ghost fret numbers on the board.
if (!infer) {
const _oLo = hsLo - ARP_FRAME_ONSET_PAD_S;
const _oHi = hsLo + ARP_FRAME_ONSET_CLUSTER_S;
let _oi = lowerBoundT(notesArr, _oLo - 0.02);
for (; _oi < notesArr.length; _oi++) {
const _on = notesArr[_oi];
if (_on.t > _oHi) break;
if (_on.t < _oLo) continue;
if (shape.get(_on.s) === _on.f) {
infer = true;
// Only suppress brackets when the handshape is NOT an
// explicit arpeggio (arp:true template / displayName "-arp").
// Genuine arp handshapes reached via onset-match still need
// the [ ] bracket markers — only non-arp synth chords are
// "false positives" that should hide the brackets.
if (outSynthOnsetSet != null
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
outSynthOnsetSet.add(hsLo);
}
break;
}
}
}
}
}
}
outFlags[i] = infer;
}
}
// Chart-static WeakMap cache: note object → chord-id (or null sentinel).
// The result depends only on the note's (t, s, f) and the chart's handShapes
// + chordTemplates, which never change after load. Keyed by note object so
// switching songs/arrangements drops the entries with the old array.
const _ARP_CID_NULL = Object.freeze({});
const _arpCidCache = new WeakMap();
function arpeggioChordIdForNoteWithInferCache(n, handShapes, chordTemplates, notesArr, hsInferFlags) {
const cached = _arpCidCache.get(n);
if (cached !== undefined) return cached === _ARP_CID_NULL ? null : cached;
let result = null;
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0 || !hsInferFlags) {
result = arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr);
} else if (validString(n.s)) {
for (let i = 0; i < handShapes.length; i++) {
if (!hsInferFlags[i]) continue;
const hs = handShapes[i];
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
const cid = hsChordIdNorm(hs);
if (cid == null) continue;
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
result = cid;
break;
}
}
_arpCidCache.set(n, result === null ? _ARP_CID_NULL : result);
return result;
}
/** Returns {start, end} chart-time bounds of the arpeggio handshape that contains
* this note, or null when not found. Uses hsInferFlags to skip ruled-out
* handshapes; falls back to a full scan when hsInferFlags is null. */
// WeakMap cache — arpHsBoundsForNote result is chart-static (note, handShapes,
// and hsInferFlags never change after chart load). Each renderer instance has
// its own WeakMap, so splitscreen panels don't interfere.
// Sentinel: _ARP_BOUNDS_NULL = {} distinguishes "no matching hs" from "uncached".
const _ARP_BOUNDS_NULL = Object.freeze({});
const _arpBoundsCache = new WeakMap();
function arpHsBoundsForNote(n, handShapes, hsInferFlags) {
if (!handShapes || handShapes.length === 0) return null;
const cached = _arpBoundsCache.get(n);
if (cached !== undefined) return cached === _ARP_BOUNDS_NULL ? null : cached;
let result = null;
for (let i = 0; i < handShapes.length; i++) {
if (hsInferFlags && !hsInferFlags[i]) continue;
const hs = handShapes[i];
const lo = hsStart(hs);
const hi = hsEnd(hs);
if (Number.isNaN(lo) || Number.isNaN(hi)) continue;
if (n.t + 1e-4 < lo || n.t > hi + 1e-4) continue;
result = { start: lo, end: hi };
break;
}
_arpBoundsCache.set(n, result === null ? _ARP_BOUNDS_NULL : result);
return result;
}
/** Cache the authored arpeggio marker per hand shape. */
function handShapeIsArpeggioForLaneRail(hs, chordTemplates) {
return handShapeMarkedArpeggio(hs, chordTemplates);
}
/**
* Chart-time window for purple rails: hand-shape span clipped to matching
* ``chords[].t`` and template notes in the passage same times that drive
* the 3D arpeggio frame (``ch.t`` + note stream), avoiding rails that start
* before the box or end before the last arpeggiated note.
*/
function effectiveArpRailChartBoundsForHandShape(hs, chords, chordTemplates, notesArr) {
let shapeLo = hsStart(hs);
const _hsEndOrig = hsEnd(hs);
let shapeHi = _hsEndOrig;
const cid = hsChordIdNorm(hs);
if (Number.isNaN(shapeLo) || Number.isNaN(shapeHi)) {
return { shapeLo: 1e9, shapeHi: -1e9 };
}
if (notesArr && notesArr.length > 0 && chordTemplates && cid != null) {
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
if (tmpl && Array.isArray(tmpl.frets)) {
let tFirst = null;
let tLast = null;
for (let i = 0; i < notesArr.length; i++) {
const n = notesArr[i];
if (n.t + 1e-4 < shapeLo - 0.18 || n.t > shapeHi + 0.45) continue;
if (!validString(n.s)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
if (tFirst === null || n.t < tFirst) tFirst = n.t;
if (tLast === null || n.t > tLast) tLast = n.t;
}
if (tFirst != null) shapeLo = Math.max(shapeLo, tFirst);
if (tLast != null) shapeHi = Math.max(shapeHi, tLast);
}
}
if (chords && chords.length && cid != null) {
let tMinC = null;
let tMaxC = null;
for (let j = 0; j < chords.length; j++) {
const ch = chords[j];
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
if (ch.t + 1e-4 < shapeLo || ch.t > shapeHi + 0.28) continue;
if (tMinC === null || ch.t < tMinC) tMinC = ch.t;
if (tMaxC === null || ch.t > tMaxC) tMaxC = ch.t;
}
if (tMinC != null) shapeLo = Math.max(shapeLo, tMinC);
if (tMaxC != null) shapeHi = Math.max(shapeHi, tMaxC);
}
shapeLo -= ARP_HWY_RAIL_START_LEAD_S;
// Only extend past the handshape end when notes/chords genuinely reach
// beyond it — otherwise the tail would make the rail visually larger
// than the actual handshape duration (e.g. 0.38 s / 1.3 s ≈ 29% extra).
if (shapeHi > _hsEndOrig) shapeHi += ARP_HWY_RAIL_END_TAIL_S;
return { shapeLo, shapeHi };
}
/** Cache the authored arpeggio marker per hand shape. */
function fillLaneRailHandShapeFlags(handShapes, chordTemplates, outFlags) {
const nHs = handShapes.length;
for (let i = 0; i < nHs; i++) {
outFlags[i] = handShapeIsArpeggioForLaneRail(handShapes[i], chordTemplates);
}
}
function fillArpeggioRailShapeBoundsCaches(
handShapes, chords, chordTemplates, notesArr, laneRailFlags, loOut, hiOut,
) {
const nHs = handShapes.length;
for (let i = 0; i < nHs; i++) {
if (!laneRailFlags[i]) continue;
const b = effectiveArpRailChartBoundsForHandShape(
handShapes[i], chords, chordTemplates, notesArr,
);
loOut[i] = b.shapeLo;
hiOut[i] = b.shapeHi;
}
}
/** ``[tChartLo,tChartHi]`` chart times that a lane slice covers (see module ``BEHIND`` / approach ``dt``). */
function arpeggioLaneOuterRailChartIntervalOverlaps(
tChartLo,
tChartHi,
handShapes,
boundLo,
boundHi,
laneRailFlags,
) {
if (!handShapes || handShapes.length === 0) return false;
if (!laneRailFlags) return false;
if (tChartHi < tChartLo) {
const s = tChartLo;
tChartLo = tChartHi;
tChartHi = s;
}
for (let i = 0; i < handShapes.length; i++) {
if (!laneRailFlags[i]) continue;
const shapeLo = boundLo[i];
const shapeHi = boundHi[i];
if (tChartHi < shapeLo - 1e-4 || tChartLo > shapeHi + 1e-4) continue;
return true;
}
return false;
}
function arpeggioLaneOuterRailLaneSlice(
dt0, dt1, nowClock,
handShapes, boundLo, boundHi, laneRailFlags,
) {
const tLo = nowClock + Math.min(dt0, dt1) - BEHIND;
const tHi = nowClock + Math.max(dt0, dt1) - BEHIND;
return arpeggioLaneOuterRailChartIntervalOverlaps(
tLo, tHi, handShapes, boundLo, boundHi, laneRailFlags,
);
}
/**
* True when **chart time** ``chartT`` falls inside an arpeggio hand-shape.
* Uses a short end tail only no ``CHORD_HWY_LINGER_S`` so purple lane
* rails match visible highway slices and do not leak after shapes end.
*/
function arpeggioLaneOuterRailAtChartTime(
chartT, handShapes, boundLo, boundHi, laneRailFlags,
) {
return arpeggioLaneOuterRailChartIntervalOverlaps(
chartT, chartT, handShapes, boundLo, boundHi, laneRailFlags,
);
}
/**
* Same ``chordAccent ? ft *= 1.22`` as the 3D arpeggio chord rim so lane
* rails match an accented frame when the active hand shape links to a
* chord row that carries ``.ac`` notes.
*/
function arpeggioLaneDividerFrameAccentMul(nowT, handShapes, chords, boundLo, boundHi, laneRailFlags) {
if (!handShapes || handShapes.length === 0 || !chords || chords.length === 0) return 1;
if (!laneRailFlags) return 1;
for (let i = 0; i < handShapes.length; i++) {
if (!laneRailFlags[i]) continue;
const shapeLo = boundLo[i];
const shapeHi = boundHi[i];
if (nowT + 1e-4 < shapeLo || nowT > shapeHi + 1e-4) continue;
const cid = hsChordIdNorm(handShapes[i]);
if (cid == null) return 1;
for (let j = 0; j < chords.length; j++) {
const ch = chords[j];
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
if (Math.abs(ch.t - hsStart(handShapes[i])) > 0.12) continue;
const chordNotes = ch.notes ? filterValidNotes(ch.notes) : [];
if (chordNotes.some(cn => cn.ac)) return 1.22;
return 1;
}
return 1;
}
return 1;
}
/** World-scale XY for purple lane rails = arpeggio ``ftSide`` / ``gLaneDivider`` edge (0.15×K). */
function arpeggioLaneDividerXYScaleMatchFrameRim(accentMul = 1) {
const yA = sY(0), yB = sY(getNStr() - 1); // DI: nStr → getNStr()
const yMinF = Math.min(yA, yB) - S_GAP * 0.8;
const yMaxF = Math.max(yA, yB) + S_GAP * 0.8;
const fullChordBoxH = yMaxF - yMinF;
let ft = Math.max(CHORD_FRAME_RIM_MIN * K, fullChordBoxH * CHORD_FRAME_RIM_FRAC_H);
if (accentMul !== 1 && accentMul > 0) ft *= accentMul;
const ftSide = ft * 1.55;
return ftSide / (0.15 * K);
}
return {
// ── exported (called from outside T-section) ──────────────────────
chordWireHighDensity, // callers: 9155, 9384, 9791, 9815
chordTemplateLabel, // callers: 9790, 10801, 10852
chordTemplateMarkedArpeggio, // callers: 9474, 10045
chordHandShapeArpeggioHint, // caller: 9112
mergeHandShapeSynthChords, // caller: 7965
mergeChordShape, // caller: 8982
resetChordShapeCache, // caller: _resetStringDependentCaches (screen.js)
inferArpeggioFromNotePattern, // caller: 9126
chordShapeCoveredByStandaloneNotes, // caller: 9134
hsStart, // callers: 8008, 9114, 9152, 9239, 9423, 10040
hsEnd, // callers: 8008, 9114, 9240, 9423, 10040
handShapeChartSpanSec, // caller: 9125
fillArpeggioGhostInferFlags, // caller: 7987
arpeggioChordIdForNoteWithInferCache, // caller: 8811
arpHsBoundsForNote, // caller: 8819
fillLaneRailHandShapeFlags, // caller: 8101
fillArpeggioRailShapeBoundsCaches, // caller: 8110
arpeggioLaneOuterRailLaneSlice, // caller: 10267
arpeggioLaneOuterRailAtChartTime, // caller: 10124
arpeggioLaneDividerFrameAccentMul, // callers: 10128, 10366
arpeggioLaneDividerXYScaleMatchFrameRim, // callers: 10133, 10371
// ── private (T-internal only, not in return) ──────────────────────
// truthyChartFlag — only used by T-internal fns
// handShapeMarkedArpeggio — only used by T-internal fns
// chordNotesFromTemplate — only used by T-internal fns
// hitTimesQualifyArpeggioSpread — only called by _inferArpeggioFromNotePatternUncached
// _inferArpeggioFromNotePatternUncached — only called by inferArpeggioFromNotePattern
// hsChordIdNorm — only used by T-internal fns
// arpeggioChordIdForNote — only called by arpeggioChordIdForNoteWithInferCache (line 7378)
// handShapeIsArpeggioForLaneRail — only called by fillLaneRailHandShapeFlags (line 7490)
// effectiveArpRailChartBoundsForHandShape — only called by fillArpeggioRailShapeBoundsCaches (line 7500)
// arpeggioLaneOuterRailChartIntervalOverlaps — only called by Slice/AtChartTime (lines 7540, 7553)
};
}
+684
View File
@@ -0,0 +1,684 @@
/**
* Butterchurn audio-reactive background control panel h3d-carve-4.
*
* Exports _bcIsDesktop() and _bcCreateController().
* window.h3dBcApplySettings is assigned at module scope so it is available
* before the IIFE runs (R5); settings.html guards the call with ?.(). All
* vendor scripts are loaded via DOM <script> injection never ES import (R2).
*/
/* Butterchurn audio-reactive background
* Mounts a Butterchurn (WebGL MilkDrop) canvas BEHIND the transparent
* 3D highway. On desktop it's driven by the guitar/mic input (the song
* audio lives in JUCE, not the webview <audio>); in a browser it taps
* the song <audio> directly.
* */
const BC_VENDOR = '/api/plugins/highway_3d/assets/vendor/';
const BC_FRAME = 1024;
const BC_WORKLET = '/api/plugins/highway_3d/assets/viz-worklet.js';
const _bcMeters = { gtr: 0, song: 0 }; // live levels shown in the panel readout
const BC_BTN = 'background:rgba(255,255,255,.09);color:#cfe3ff;border:1px solid rgba(255,255,255,.16);border-radius:5px;padding:3px 8px;cursor:pointer;font:12px system-ui';
let _bcLoading = null;
function _bcLoadLib() {
if (_bcLoading) return _bcLoading;
_bcLoading = new Promise((resolve, reject) => {
const add = (url, next) => {
const s = document.createElement('script');
s.src = url; s.async = true;
s.onload = next; s.onerror = () => reject(new Error('load ' + url));
document.head.appendChild(s);
};
add(BC_VENDOR + 'butterchurn.min.js', () =>
add(BC_VENDOR + 'butterchurnPresets.min.js', resolve));
});
// Don't cache a rejected promise: a transient load failure (network
// hiccup, blocked request) must not permanently disable the feature for
// the session. Clearing _bcLoading lets the next mount retry the load.
_bcLoading.catch(() => { _bcLoading = null; });
return _bcLoading;
}
function _bcResolve() { let b = window.butterchurn; if (b && b.default) b = b.default; return b; }
function _bcPresets() { let p = window.butterchurnPresets; if (p && p.default) p = p.default; return p; }
export function _bcIsDesktop() {
const d = window.feedBackDesktop || window.slopsmithDesktop;
return !!(d && d.isDesktop && d.audio && typeof d.audio.getRawAudioFrame === 'function');
}
// Fast-forward an index to the first entry after time `ct` (used on seek/loop).
// Position at the first entry whose time is >= ct (strict <), so an event
// landing exactly on the seek/loop target time is still fired by the update
// walkers (which consume `<= ct`) instead of being skipped past here.
export function _bcFfIdx(arr, ct, key) { if (!arr) return 0; let i = 0; while (i < arr.length && (arr[i][key] || 0) < ct) i++; return i; }
// Force-free a canvas's WebGL context so the GPU resources are released
// immediately instead of lingering until GC — repeated Butterchurn
// mount/unmount cycles otherwise pile up live contexts toward the browser cap.
function _bcReleaseCanvasGL(canvas) {
if (!canvas || typeof canvas.getContext !== 'function') return;
let gl = null;
try { gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); } catch (e) { gl = null; }
if (!gl || typeof gl.getExtension !== 'function') return;
try { const lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext(); } catch (e) {}
}
// Desktop: bridge GUITAR input PCM + SONG output level into a Web Audio node
// Butterchurn can tap. Guitar gives spectral texture from your playing; the
// song's output meter (getLevels) injects an energy pulse so the visuals also
// react to the backing track (JUCE plays it — there's no song PCM to FFT).
function _bcGuitarFeed(actx, onReady) {
const latest = new Float32Array(BC_FRAME);
let polling = true, songLevel = 0, chartLevel = 0;
let node = null, sp = null, silent = null;
const api = (window.feedBackDesktop || window.slopsmithDesktop).audio;
const gainNow = () => (_bcLoadSettings().guitarGain) || 6;
// Keep the source node processing (silently — JUCE already monitors the
// guitar), and hand it to Butterchurn via the onReady callback.
function attach(srcNode) {
silent = actx.createGain(); silent.gain.value = 0;
srcNode.connect(silent); silent.connect(actx.destination);
try { if (onReady) onReady(srcNode); } catch (e) {}
}
// Fallback for contexts without AudioWorklet support.
function useScriptProcessor() {
let phase = 0, phase2 = 0;
const TWO_PI = Math.PI * 2;
const oscStep = TWO_PI * (90 / actx.sampleRate);
const oscStep2 = TWO_PI * (520 / actx.sampleRate);
sp = actx.createScriptProcessor(BC_FRAME, 1, 1);
sp.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
const n = Math.min(out.length, latest.length);
const lvl = songLevel, clvl = chartLevel, gg = gainNow();
for (let i = 0; i < out.length; i++) {
const g = (i < n ? latest[i] : 0) * gg;
const song = lvl * (0.7 * Math.sin(phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
const chart = clvl * (0.5 * Math.sin(phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
phase += oscStep; if (phase > TWO_PI) phase -= TWO_PI;
phase2 += oscStep2; if (phase2 > TWO_PI) phase2 -= TWO_PI;
const v = g + song + chart;
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
}
};
attach(sp);
console.log('[viz3d] audio feed: ScriptProcessor (fallback)');
}
// Preferred path: AudioWorklet (runs off the main thread).
if (actx.audioWorklet && typeof actx.audioWorklet.addModule === 'function' && typeof AudioWorkletNode === 'function') {
actx.audioWorklet.addModule(BC_WORKLET).then(() => {
if (!polling || sp) return;
node = new AudioWorkletNode(actx, 'viz-feed', { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
attach(node);
console.log('[viz3d] audio feed: AudioWorklet');
}).catch((e) => {
console.warn('[viz3d] AudioWorklet unavailable, using ScriptProcessor:', e && e.message);
if (polling && !sp && !node) useScriptProcessor();
});
} else {
useScriptProcessor();
}
// Guitar PCM poll → waveform + level meter (+ pushed to the worklet).
(function pcmLoop() {
if (!polling) return;
Promise.resolve(api.getRawAudioFrame(BC_FRAME)).then((f) => {
if (f && f.length) {
if (f.length >= BC_FRAME) latest.set(f.subarray(0, BC_FRAME));
else { latest.fill(0); latest.set(f); }
let s = 0; for (let i = 0; i < BC_FRAME; i++) s += latest[i] * latest[i];
_bcMeters.gtr = Math.sqrt(s / BC_FRAME) * gainNow();
if (node) node.port.postMessage({ frame: latest.slice(0), song: songLevel, chart: chartLevel, gain: gainNow() });
}
}).catch(() => {}).then(() => { if (polling) setTimeout(pcmLoop, 16); });
})();
// Song output meter poll → music energy pulse.
(function levelLoop() {
if (!polling) return;
Promise.resolve(api.getLevels && api.getLevels()).then((L) => {
if (L && typeof L.outputLevel === 'number') {
songLevel = Math.min(1, L.outputLevel * ((_bcLoadSettings().songGain) || 1.8));
_bcMeters.song = songLevel;
if (node) node.port.postMessage({ song: songLevel, chart: chartLevel, gain: gainNow() });
}
}).catch(() => {}).then(() => { if (polling) setTimeout(levelLoop, 40); });
})();
return {
setChart(v) { chartLevel = v; },
stop() {
polling = false;
try { if (sp) { sp.disconnect(); sp.onaudioprocess = null; } } catch (e) {}
try { if (node) node.disconnect(); } catch (e) {}
try { if (silent) silent.disconnect(); } catch (e) {}
}
};
}
// Browser audio is sourced by REUSING the highway's own shared analyser
// (the same #audio / stems side-chain tap the fog scenery uses), passed in
// as `audioProvider` to _bcCreateController. We deliberately do NOT open a
// second createMediaElementSource on #audio here: it can only be called
// once per element (a second tap throws InvalidStateError and permanently
// disables the other consumer), it would route the song through a fresh,
// possibly-suspended context and mute playback, and it would miss the stems
// side-chain that sloppaks expose at window.feedBack.stems.getAnalyser().
/* ── Controls + readability (localStorage-backed, global config) ───── */
const BC_LS = 'viz3d_settings';
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8, cyclePool: 'all', hold: false };
let _bcSettings = null;
export function _bcLoadSettings() {
if (_bcSettings) return _bcSettings;
let saved = {};
try { saved = JSON.parse(localStorage.getItem(BC_LS) || '{}'); } catch (e) {}
_bcSettings = Object.assign({}, BC_DEFAULTS, saved);
return _bcSettings;
}
function _bcSaveSettings() { try { localStorage.setItem(BC_LS, JSON.stringify(_bcSettings)); } catch (e) {} }
const _bcControllers = new Set();
function _bcApplyAll() { _bcControllers.forEach((c) => { try { c.applySettings(); } catch (e) {} }); }
// Live-apply hook for the plugin's settings.html. The visualizer's on/off +
// slider controls now live in the standard settings panel (settings.html),
// which persists them into the BC_LS blob and then calls this so a mounted
// highway re-reads and applies them immediately. Assigned at module scope
// (R5: h3d-carve-4 — 1 beyond-subst vs the IIFE placement; body verbatim)
// so it is available before the IIFE runs. settings.html guards with `?.`.
window.h3dBcApplySettings = function () {
_bcSettings = null; // drop the cache so the next read reloads from localStorage
_bcLoadSettings();
_bcApplyAll();
try { _bcUpdatePanelPreset(); } catch (e) {}
};
// Preset curation: favorites / bans (persisted globally) + the "primary"
// controller the panel's preset buttons drive.
// Seeded once on first run (reputation-based starter set; user can edit freely).
const BC_DEFAULT_FAVORITES = [
'Flexi, martin + geiss - dedicated to the sherwin maxawow',
'Geiss - Reaction Diffusion 2',
'Geiss - Spiral Artifact',
'Flexi + Martin - cascading decay swing',
'Flexi - mindblob [shiny mix]',
'Geiss - Cauldron - painterly 2 (saturation remix)',
'Zylot - Paint Spill (Music Reactive Paint Mix)',
'Flexi - predator-prey-spirals',
'Rovastar + Loadus + Geiss - FractalDrop (Triple Mix)',
'Flexi, fishbrain, Geiss + Martin - tokamak witchery',
];
const BC_DEFAULT_BANS = [
'martin - mucus cervix',
'Goody - The Wild Vort',
'martin - extreme heat',
'Unchained - Rewop',
'high-altitude basket unraveling - singh grooves nitrogen argon nz+',
'$$$ Royal - Mashup (197)',
'$$$ Royal - Mashup (431)',
'suksma - uninitialized variabowl (hydroponic chronic)',
'shifter - dark tides bdrv mix 2',
'_Mig_049',
];
const _bcFavorites = new Set();
const _bcBanned = new Set();
let _bcListsLoaded = false;
function _bcLoadLists() {
if (_bcListsLoaded) return; _bcListsLoaded = true;
try { (JSON.parse(localStorage.getItem('viz3d_favorites') || '[]') || []).forEach((n) => _bcFavorites.add(n)); } catch (e) {}
try { (JSON.parse(localStorage.getItem('viz3d_banned') || '[]') || []).forEach((n) => _bcBanned.add(n)); } catch (e) {}
let seeded = false;
try { seeded = !!localStorage.getItem('viz3d_seeded'); } catch (e) {}
if (!seeded) {
BC_DEFAULT_FAVORITES.forEach((n) => _bcFavorites.add(n));
BC_DEFAULT_BANS.forEach((n) => _bcBanned.add(n));
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
_bcSaveLists();
}
}
function _bcSaveLists() {
try { localStorage.setItem('viz3d_favorites', JSON.stringify([..._bcFavorites])); } catch (e) {}
try { localStorage.setItem('viz3d_banned', JSON.stringify([..._bcBanned])); } catch (e) {}
}
// Re-add the bundled defaults anytime (merges; a default-fav un-bans, a default-ban un-favs).
function _bcRestoreDefaults() {
BC_DEFAULT_FAVORITES.forEach((n) => { _bcBanned.delete(n); _bcFavorites.add(n); });
BC_DEFAULT_BANS.forEach((n) => { _bcFavorites.delete(n); _bcBanned.add(n); });
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
_bcSaveLists(); _bcUpdatePanelPreset(); _bcRenderList();
}
let _bcPrimary = null;
let _bcPane = null, _bcListEl = null, _bcFilterEl = null, _bcPaneOpen = false, _bcCollapsed = false;
function _bcStatusMark(name) {
return _bcFavorites.has(name) ? '★ ' : (_bcBanned.has(name) ? '🚫 ' : '');
}
// Hoisted above the functions that reference it so no-use-before-define
// does not flag closure reads inside _bcSetHold, _bcLayout, _bcSetPane,
// _bcUpdatePanelPreset. All are closures — _bcPanel is read at call time,
// not at module-evaluation time. Original declaration was at line ~316.
let _bcPanel = null, _bcPanelKeyBound = false;
function _bcSetHold(v) {
const s = _bcLoadSettings();
s.hold = !!v; _bcSaveSettings();
const b = _bcPanel && _bcPanel.querySelector('#vz-hold');
if (b) b.textContent = s.hold ? '▶ Resume' : '⏸ Hold';
}
// Drives both panels off the right edge. Order when both open (L→R):
// visualizer panel → preset pane → window edge. Pane lives off-screen by
// default; opening it shoves the panel LEFT to make room.
function _bcLayout() {
if (_bcPanel) {
let tx = 0;
if (_bcCollapsed) tx = 210; // tuck the whole panel off the right edge
else if (_bcPaneOpen) tx = -248; // slide panel LEFT to make room for the pane
_bcPanel.style.transform = 'translateX(' + tx + 'px) translateY(-50%)';
}
if (_bcPane) {
_bcPane.style.transform = (_bcPaneOpen && !_bcCollapsed) ? 'translateX(0) translateY(-50%)' : 'translateX(calc(100% + 16px)) translateY(-50%)';
}
}
function _bcSetPane(open) {
_bcPaneOpen = !!open && !_bcCollapsed;
const b = _bcPanel && _bcPanel.querySelector('#vz-listbtn');
if (b) b.textContent = _bcPaneOpen ? '>>' : '<<';
if (_bcPaneOpen) _bcRenderList();
_bcLayout();
}
function _bcRenderList() {
if (!_bcListEl) return;
const ctrl = _bcPrimary;
const keys = (ctrl && ctrl.keys) ? ctrl.keys : [];
const filt = ((_bcFilterEl && _bcFilterEl.value) || '').toLowerCase();
const cur = ctrl && ctrl.curName;
const frag = document.createDocumentFragment();
for (let i = 0; i < keys.length; i++) {
const name = keys[i];
if (filt && name.toLowerCase().indexOf(filt) === -1) continue;
const row = document.createElement('div');
row.textContent = _bcStatusMark(name) + name;
row.title = name;
row.style.cssText = 'padding:3px 7px;border-radius:4px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;' +
(name === cur ? 'background:rgba(110,160,255,.28);' : '') + (_bcBanned.has(name) ? 'opacity:.55;' : '');
row.addEventListener('click', () => {
if (!_bcPrimary) return;
_bcPrimary.loadByName(name, 1.0);
_bcSetHold(true); // picked from the list → sit on it
});
frag.appendChild(row);
}
_bcListEl.innerHTML = '';
_bcListEl.appendChild(frag);
}
function _bcUpdatePanelPreset() {
if (!_bcPanel) return;
const name = _bcPrimary ? (_bcPrimary.curName || null) : null;
const nameEl = _bcPanel.querySelector('#vz-pname');
const favBtn = _bcPanel.querySelector('#vz-fav');
const banBtn = _bcPanel.querySelector('#vz-ban');
const cntEl = _bcPanel.querySelector('#vz-pcount');
if (nameEl) { nameEl.textContent = (name ? _bcStatusMark(name) : '') + (name || '—'); nameEl.title = name ? (name + ' — click for full list') : ''; }
if (favBtn) favBtn.textContent = (name && _bcFavorites.has(name)) ? '★ Favorited' : '☆ Favorite';
if (banBtn) banBtn.textContent = (name && _bcBanned.has(name)) ? '🚫 Banned' : '🚫 Ban';
if (cntEl) cntEl.textContent = '★ ' + _bcFavorites.size + ' 🚫 ' + _bcBanned.size;
if (_bcPaneOpen) _bcRenderList();
}
// _bcPanel hoisted to before _bcSetHold — see comment there.
function _bcEnsurePanel(host) {
if (_bcPanel && _bcPanel.isConnected) {
// Singleton panel: follow the active highway. If it's still parented
// to a different wrap (e.g. another mounted highway instance such as
// Virtuoso's embedded one), move it — and the pane — to this wrap so
// it appears on whichever highway is currently on-screen.
if (host && _bcPanel.parentNode !== host) {
host.appendChild(_bcPanel);
if (_bcPane) host.appendChild(_bcPane);
}
return _bcPanel;
}
const s = _bcLoadSettings();
const p = document.createElement('div');
p.id = 'viz3d-panel';
p.style.cssText = 'position:absolute;top:50%;right:10px;z-index:100000;pointer-events:auto;font:12px/1.45 system-ui,sans-serif;' +
'color:#cfe3ff;background:rgba(8,10,20,0.82);padding:9px 11px;border-radius:8px;width:186px;' +
'box-shadow:0 2px 12px rgba(0,0,0,0.5);user-select:none;transition:transform 0.28s ease;';
p.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px"><span style="font-weight:600">🌀 Visualizer</span><button id="vz-listbtn" title="Show / hide full preset list" style="' + BC_BTN + ';padding:1px 7px">&lt;&lt;</button></div>' +
// On/off + opacity/dim/chart/tint/gain controls now live in the
// plugin's Settings panel (settings.html). This in-canvas panel is
// only the LIVE preset browser (pick / favorite / ban / cycle).
'<div style="opacity:.55;font-size:11px;margin:2px 0 6px">Background &amp; reactivity options are in Settings ▸ 3D Highway.</div>' +
'<div style="display:flex;align-items:center;gap:6px;margin:4px 0">' +
'<button id="vz-prev" style="' + BC_BTN + '">◀</button>' +
'<div id="vz-pname" style="flex:1;text-align:center;font-size:11px;opacity:.9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" title="">—</div>' +
'<button id="vz-next" style="' + BC_BTN + '">▶</button>' +
'</div>' +
'<div style="display:flex;gap:6px;margin:4px 0">' +
'<button id="vz-fav" style="' + BC_BTN + ';flex:1">♡ Favorite</button>' +
'<button id="vz-ban" style="' + BC_BTN + ';flex:1">🚫 Ban</button>' +
'</div>' +
'<div style="display:flex;gap:6px;align-items:flex-end;margin:6px 0">' +
'<label style="flex:1">Cycle <select id="vz-cyc" style="width:100%;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;padding:3px"><option value="all">All</option><option value="favorites">Favorites</option><option value="bans">Bans</option></select></label>' +
'<button id="vz-hold" style="' + BC_BTN + '">⏸ Hold</button>' +
'</div>' +
'<div style="margin:5px 0 4px;font-size:11px;opacity:.75"><span id="vz-pcount">★ 0 🚫 0</span></div>' +
'<div id="vz-meter" style="opacity:.65;margin-top:6px;font:11px/1.3 monospace">gtr — · song —</div>' +
'<div style="opacity:.45;margin-top:4px;font-size:11px">` or ‹‹ to hide</div>';
(host || document.body).appendChild(p);
// Slide handle (<< / >>) so the panel can tuck off the right edge and stop
// covering the Now / Up-Next labels.
const tab = document.createElement('button');
tab.textContent = '>>';
tab.title = 'Hide / show controls';
tab.style.cssText = 'position:absolute;top:6px;left:-23px;width:23px;height:28px;border:none;cursor:pointer;' +
'background:rgba(8,10,20,0.82);color:#cfe3ff;border-radius:7px 0 0 7px;font:12px/1 monospace;padding:0;';
p.appendChild(tab);
tab.addEventListener('click', () => {
_bcCollapsed = !_bcCollapsed;
if (_bcCollapsed) _bcPaneOpen = false; // collapsing the panel hides the pane too
tab.textContent = _bcCollapsed ? '<<' : '>>';
const lb = p.querySelector('#vz-listbtn'); if (lb) lb.textContent = _bcPaneOpen ? '>>' : '<<';
_bcLayout();
});
// Sliding preset-list pane (sits to the LEFT of the control panel)
const pane = document.createElement('div');
pane.id = 'viz3d-listpane';
pane.style.cssText = 'position:absolute;top:50%;right:10px;z-index:99999;pointer-events:auto;width:236px;max-height:74vh;display:flex;flex-direction:column;' +
'background:rgba(8,10,20,0.93);border-radius:8px;box-shadow:0 2px 14px rgba(0,0,0,0.55);color:#cfe3ff;' +
'font:12px system-ui,sans-serif;overflow:hidden;transform:translateX(calc(100% + 16px)) translateY(-50%);transition:transform 0.28s ease;';
pane.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 9px 7px 10px;font-weight:600;border-bottom:1px solid rgba(255,255,255,.1)"><span>Presets</span><button id="vz-defaults" title="Restore the bundled default favorites + bans" style="' + BC_BTN + ';font-weight:400">↺ defaults</button></div>' +
'<input id="vz-filter" placeholder="filter…" spellcheck="false" style="margin:8px 9px 6px;padding:4px 7px;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;outline:none">' +
'<div id="vz-list" style="overflow-y:auto;padding:0 4px 8px"></div>';
(host || document.body).appendChild(pane);
_bcPane = pane;
_bcListEl = pane.querySelector('#vz-list');
_bcFilterEl = pane.querySelector('#vz-filter');
_bcFilterEl.addEventListener('input', _bcRenderList);
pane.querySelector('#vz-defaults').addEventListener('click', _bcRestoreDefaults);
const q = (id) => p.querySelector(id);
_bcPanel = p;
// Preset curation wiring (favorites / bans / cycle / reset)
_bcLoadLists();
const cyc = q('#vz-cyc');
cyc.value = s.cyclePool || 'all';
// Read fresh: settings.html writes can replace _bcSettings, so the `s`
// captured at panel creation may be stale by the time this fires.
cyc.addEventListener('change', () => { _bcLoadSettings().cyclePool = cyc.value; _bcSaveSettings(); });
_bcSetHold(!!s.hold); // sync the Hold button label to the saved state
q('#vz-hold').addEventListener('click', () => _bcSetHold(!_bcLoadSettings().hold));
q('#vz-listbtn').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
q('#vz-pname').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
q('#vz-prev').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(-1); });
q('#vz-next').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(1); });
q('#vz-fav').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.toggleFav(); });
q('#vz-ban').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.banCur(); });
_bcSetPane(false); // start collapsed; sets the list-button label
_bcUpdatePanelPreset();
// Live level readout — proves the song (not just guitar) is driving things.
// Self-stops when the panel is removed (_bcPanel !== p).
(function meterLoop() {
if (_bcPanel !== p) return;
const m = p.querySelector('#vz-meter');
if (m) m.textContent = 'gtr ' + _bcMeters.gtr.toFixed(2) + ' · song ' + _bcMeters.song.toFixed(2);
setTimeout(meterLoop, 150);
})();
if (!_bcPanelKeyBound) {
_bcPanelKeyBound = true;
window.addEventListener('keydown', (e) => {
if (e.key !== '`' || e.metaKey || e.ctrlKey || !_bcPanel) return;
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const reveal = _bcPanel.style.display === 'none';
_bcPanel.style.display = reveal ? '' : 'none';
if (_bcPane) _bcPane.style.display = reveal ? '' : 'none';
});
}
return _bcPanel;
}
// Create a Butterchurn background controller bound to a wrap element.
export function _bcCreateController(wrap, sizeProvider, audioProvider) {
const ctrl = { viz: null, actx: null, guitar: null, map: null, keys: [], cycle: 0, dead: false, lastW: -1, lastH: -1, canvas: null, backdrop: null, scrim: null, tint: null, wrap: wrap };
// Layered DOM in the wrap, all BEHIND the transparent 3D highway:
// backdrop(z-4 dark) → bc canvas(z-3) → tint(z-2 instrument color) → scrim(z-1 lane dim)
const mkLayer = (cls, css) => { const d = document.createElement('div'); d.className = cls; d.style.cssText = css; wrap.appendChild(d); return d; };
const backdrop = mkLayer('viz3d-backdrop', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-4;background:#070710;pointer-events:none;');
const canvas = document.createElement('canvas');
canvas.className = 'viz3d-bc';
canvas.style.cssText = 'position:absolute;top:0;left:0;z-index:-3;pointer-events:none;';
wrap.appendChild(canvas);
const tint = mkLayer('viz3d-tint', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-2;pointer-events:none;mix-blend-mode:overlay;background:transparent;');
const scrim = mkLayer('viz3d-scrim', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1;pointer-events:none;');
ctrl.canvas = canvas; ctrl.backdrop = backdrop; ctrl.scrim = scrim; ctrl.tint = tint;
ctrl.applySettings = function () {
const s = _bcLoadSettings();
canvas.style.display = s.enabled ? '' : 'none';
canvas.style.opacity = String(s.enabled ? s.opacity : 0);
if (s.laneDim) {
const a = Math.max(0, Math.min(1, s.laneDimStrength)).toFixed(3);
scrim.style.display = '';
scrim.style.background = 'linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,' + a +
') 30%, rgba(0,0,0,' + a + ') 70%, rgba(0,0,0,0) 100%)';
} else {
scrim.style.display = 'none';
}
};
// ── Preset curation (favorites / bans / cycle mode) ──
ctrl.curName = null; ctrl.lastManual = 0;
ctrl.allList = () => (ctrl.keys || []).filter((k) => !_bcBanned.has(k));
ctrl.pool = () => {
const mode = _bcLoadSettings().cyclePool || 'all';
if (mode === 'bans') return (ctrl.keys || []).filter((k) => _bcBanned.has(k));
if (mode === 'favorites') {
const f = (ctrl.keys || []).filter((k) => _bcFavorites.has(k) && !_bcBanned.has(k));
if (f.length) return f;
}
return ctrl.allList();
};
ctrl.browseArr = () => ctrl.keys || []; // ◀▶ and the list pane walk the full preset list
ctrl.loadByName = (name, blend) => {
if (!ctrl.viz || !name || !ctrl.map || !ctrl.map[name]) return;
try { ctrl.viz.loadPreset(ctrl.map[name], blend || 0); ctrl.curName = name; } catch (e) {}
_bcUpdatePanelPreset();
};
ctrl.autoTick = () => {
if (ctrl.dead || _bcLoadSettings().hold) return;
if (performance.now() - ctrl.lastManual < 8000) return;
const pool = ctrl.pool();
if (!pool.length) return;
let name = pool[(Math.random() * pool.length) | 0];
if (pool.length > 1 && name === ctrl.curName) name = pool[(pool.indexOf(name) + 1) % pool.length];
ctrl.loadByName(name, 2.7);
};
ctrl.step = (dir) => {
const list = ctrl.browseArr();
if (!list.length) return;
let i = list.indexOf(ctrl.curName); if (i < 0) i = (dir > 0 ? -1 : 0);
i = (i + dir + list.length) % list.length;
ctrl.lastManual = performance.now();
ctrl.loadByName(list[i], 1.5);
};
ctrl.toggleFav = () => {
if (!ctrl.curName) return;
if (_bcFavorites.has(ctrl.curName)) _bcFavorites.delete(ctrl.curName);
else { _bcFavorites.add(ctrl.curName); _bcBanned.delete(ctrl.curName); }
_bcSaveLists(); _bcUpdatePanelPreset();
};
ctrl.banCur = () => {
if (!ctrl.curName) return;
if (_bcBanned.has(ctrl.curName)) { // un-ban (two-way) — stay on it
_bcBanned.delete(ctrl.curName);
_bcSaveLists(); _bcUpdatePanelPreset();
} else { // ban + advance off it
_bcBanned.add(ctrl.curName); _bcFavorites.delete(ctrl.curName);
_bcSaveLists(); ctrl.step(1);
}
};
_bcPrimary = ctrl;
_bcControllers.add(ctrl);
_bcEnsurePanel(wrap);
ctrl.applySettings();
_bcLoadLib().then(() => {
if (ctrl.dead) return;
const bc = _bcResolve();
if (!bc || typeof bc.createVisualizer !== 'function') { console.warn('[viz3d] Butterchurn global missing'); return; }
const Ctx = window.AudioContext || window.webkitAudioContext;
const sz = (sizeProvider && sizeProvider()) || { w: 1280, h: 720 };
// Browser (Docker/web app): REUSE the highway's existing shared
// analyser (the fog scenery's #audio / stems tap) via audioProvider,
// and build Butterchurn on that SAME AudioContext so connectAudio()
// doesn't fail cross-context. Desktop uses its own context fed by the
// guitar/mic input. `ownsActx` tracks whether WE created the context
// (so destroy() closes only contexts we own, never the shared one).
const fogAudio = _bcIsDesktop() ? null : (audioProvider ? audioProvider() : null);
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
// render size and report that SAME size to Butterchurn. Its on-screen
// pass viewports to the reported size but never sizes the output canvas
// itself — leaving the buffer at the 300x150 default blits the whole
// visualizer into a corner that CSS then stretches across the highway.
// pixelRatio:1 because DPR is now folded into the reported size, so
// buffer == viewport == internal texsize (no double-counting).
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
canvas.width = _bcW0; canvas.height = _bcH0;
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
width: _bcW0, height: _bcH0,
pixelRatio: 1, textureRatio: 1,
});
if (_bcIsDesktop()) {
try {
ctrl.guitar = _bcGuitarFeed(ctrl.actx, (srcNode) => { try { if (ctrl.viz) ctrl.viz.connectAudio(srcNode); } catch (e) {} });
console.log('[viz3d] bg: feeding GUITAR input into Butterchurn');
} catch (e) { console.warn('[viz3d] guitar feed failed', e); }
} else if (fogAudio && fogAudio.analyser) {
// The shared AnalyserNode is a passthrough — connecting it onward
// to Butterchurn's internal analyser doesn't disturb the fog's reads.
try { ctrl.viz.connectAudio(fogAudio.analyser); console.log('[viz3d] browser: Butterchurn tapping shared analyser (' + (fogAudio.source || 'core') + ')'); }
catch (e) { console.warn('[viz3d] shared-analyser connect failed', e); }
}
_bcLoadLists();
const presets = _bcPresets();
if (presets && typeof presets.getPresets === 'function') { ctrl.map = presets.getPresets(); ctrl.keys = Object.keys(ctrl.map); }
const pool0 = ctrl.pool();
ctrl.loadByName(pool0.length ? pool0[(Math.random() * pool0.length) | 0] : (ctrl.keys[0] || null), 0.0);
ctrl.cycle = setInterval(() => ctrl.autoTick(), 30000);
ctrl.connectedAnalyser = (fogAudio && fogAudio.analyser) || null;
console.log('[viz3d] Butterchurn ready, presets:', ctrl.keys.length);
}).catch((e) => {
// Async init failed (lib load, WebGL/context creation, etc.). Clean up
// the half-mounted controller so we don't leak an owned AudioContext /
// DOM layers, and mark it dead so _bcSyncMode can retry on a later
// mount instead of seeing a live-looking but non-functional bcCtrl.
console.error('[viz3d] Butterchurn load/init failed', e);
try { _bcReleaseCanvasGL(ctrl.canvas); } catch (_) {}
try { if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; } } catch (_) {}
try { [ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); }); } catch (_) {}
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') { try { ctrl.actx.close(); } catch (_) {} }
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
_bcControllers.delete(ctrl);
});
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
// device-pixel render size AND report that same size, so buffer ==
// on-screen viewport == full fill. Butterchurn never sizes the output
// canvas itself; the previous code set only CSS size, leaving the buffer
// at the 300x150 default -> the viz showed a stretched lower-left corner
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
function _bcApplySize(cssW, cssH) {
if (!(cssW > 0 && cssH > 0)) return;
ctrl.lastW = cssW; ctrl.lastH = cssH;
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
if (canvas.width !== bw) canvas.width = bw;
if (canvas.height !== bh) canvas.height = bh;
const wpx = cssW + 'px', hpx = cssH + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
}
return {
applySettings() { ctrl.applySettings(); },
dead() { return ctrl.dead; },
ready() { return !!ctrl.viz; },
boundAnalyser() { return ctrl.connectedAnalyser || null; },
audioCtx() { return ctrl.actx; },
// Re-bind audio when the shared analyser changes (e.g. a stems song
// swap replaces the analyser). Same context → cheap reconnect; the
// caller handles a context change with a full rebuild (cross-context
// connectAudio is impossible — the visualizer is bound to one ctx).
reconnectAudio(a) {
if (!a || !a.analyser || !ctrl.viz) return false;
if (a.analyser === ctrl.connectedAnalyser) return true;
if (a.ctx && a.ctx !== ctrl.actx) return false; // needs rebuild
try { ctrl.viz.connectAudio(a.analyser); ctrl.connectedAnalyser = a.analyser; return true; } catch (e) { return false; }
},
chart(v) { if (ctrl.guitar && ctrl.guitar.setChart) ctrl.guitar.setChart(v); },
tint(hex, alpha) {
if (!ctrl.tint) return;
if (hex == null) { ctrl.tint.style.background = 'transparent'; return; }
const r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
ctrl.tint.style.background = 'rgba(' + r + ',' + g + ',' + b + ',' + (alpha || 0).toFixed(3) + ')';
},
render() {
const s = _bcLoadSettings();
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
const sz = sizeProvider && sizeProvider();
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
_bcApplySize(sz.w, sz.h);
}
try { ctrl.viz.render(); } catch (e) {}
},
resize(w, h) { _bcApplySize(w, h); },
destroy() {
ctrl.dead = true;
_bcControllers.delete(ctrl);
if (_bcPrimary === ctrl) { _bcPrimary = _bcControllers.values().next().value || null; _bcUpdatePanelPreset(); }
if (ctrl.cycle) { clearInterval(ctrl.cycle); ctrl.cycle = 0; }
if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; }
// Release the Butterchurn WebGL context deterministically (don't
// wait for GC) so repeated mounts/toggles can't exhaust the
// browser's WebGL context cap (~16). Do it before removing the
// canvas from the DOM.
_bcReleaseCanvasGL(ctrl.canvas);
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); });
ctrl.viz = null; ctrl.connectedAnalyser = null;
// Close the AudioContext only if we own it (desktop, or the
// browser fallback). The browser path normally reuses the
// highway's shared context, which the fog system owns — never
// close that. Without this, desktop leaks a new AudioContext per
// mount and hits the browser's ~6-context cap after a few toggles.
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') {
try { ctrl.actx.close(); } catch (e) {}
}
ctrl.actx = null;
if (_bcControllers.size === 0) {
if (_bcPanel && _bcPanel.parentNode) _bcPanel.parentNode.removeChild(_bcPanel);
if (_bcPane && _bcPane.parentNode) _bcPane.parentNode.removeChild(_bcPane);
_bcPanel = null; _bcPane = null; _bcListEl = null; _bcFilterEl = null; _bcPaneOpen = false;
} else if (_bcPrimary && _bcPrimary.wrap) {
// Splitscreen: a controller other than this one is still
// alive. The singleton panel was parented to THIS (now
// destroyed) wrap, so re-home it onto the surviving primary's
// wrap — otherwise the panel is orphaned on the dead wrap and
// the surviving highway is left with no visualizer controls
// (_bcEnsurePanel only runs at controller creation). It moves
// the existing panel+pane when connected, or rebuilds them on
// the survivor if this wrap was already detached.
try { _bcEnsurePanel(_bcPrimary.wrap); _bcUpdatePanelPreset(); } catch (e) {}
}
},
};
}
+441
View File
@@ -0,0 +1,441 @@
// h3d-carve-5: player-chrome background control
//
// Verbatim move of the H-section from screen.js (_pc* symbols, lines 3057-3476
// pre-cut). IIFE-scope dependencies injected via factory DI so the module has
// no side-effects at import time.
//
// Beyond-subst (2):
// 1. Factory wrapper — closure vars become DI params.
// 2. `_venueSceneOverride` → `getVenueSceneOverride()` (live accessor, 1 call
// site in _pcSync at what was screen.js:3220).
//
// screen.js usage:
// import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
// const { _pcAcquire, _pcRelease } = createBgControl({
// BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,
// getVenueSceneOverride: () => _venueSceneOverride,
// });
export function createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe, getVenueSceneOverride }) {
/* ======================================================================
* Player-chrome background control
* ======================================================================
* A Background picker mounted into the player's Plugins rail popover, so
* the background can be switched MID-SONG without leaving for Settings.
*
* It writes through the SAME global setters settings.html uses
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
* is persisted here, and the option list is generated from BG_STYLE_IDS
* add a style there and it shows up in both places automatically.
*
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
* instances but these settings are global a panel may set a per-panel
* override, but this single shared control only ever reads/writes the
* global slot (via _bgReadGlobal), so N copies would be N ways to set
* one value. init() acquires, destroy() releases,
* and the last release unmounts so the control disappears when the user
* switches to a non-3D renderer instead of lingering as a dead knob.
*
* Everything here is event-driven. No DOM work on a per-frame path.
*/
// Wording is kept verbatim in sync with settings.html's <option> text so
// the same style is not named two different things in two UIs that sit
// two clicks apart. An id with no entry here falls back to the raw id.
const _PC_LABELS = {
off: 'Off', particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image', video: 'Custom video',
};
// Which settings each background style actually consumes, so a control
// that would do nothing is greyed out instead of lying.
//
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
// build() reads settings.intensity, and uses `reactive` if its update()
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
// drives its own audio tap and canvas opacity (only the fog-scenery half
// falls through to BG_STYLES.off). So neither knob here reaches it - both
// are false, and the tooltip points at Butterchurn's own controls.
//
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
// and its row is not updated, the control stays greyed out and lies the
// other way. An id missing from this table defaults to both-enabled, which
// is the safe direction: a new style is assumed to use its settings.
const _PC_USES = {
off: { intensity: false, reactive: false, why: 'No background to adjust' },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
// active it is the EFFECTIVE style, so both knobs drive nothing.
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
};
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
// Non-disabled wrappers around the two greyable controls. A native-disabled
// <button>/<input> receives no pointer events, so its `title` tooltip never
// shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host
// that does not provide it gets no control (and no error) - the Settings
// page remains the way in.
function _pcSlot() {
try {
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
// precedent). The playerControlSlot typeof check below already
// covers the practical case - only v3 exposes it - but the
// documented checklist asks plugins to detect v3 explicitly.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
return typeof fn === 'function' ? fn() : null;
} catch (_) { return null; }
}
// Visual language: these controls sit in the player's Plugin Controls
// popover alongside pills from other plugins (Invert, Split, Tuner, the
// STEMS group...), so they follow the same look - small rounded pills,
// dark fill, brighter on hover, tinted when active.
//
// Styled INLINE rather than with the Tailwind classes those plugins use
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
// compiled stylesheet and several of those utilities are not in it, so
// using them would mean regenerating assets/plugin.css and bumping the
// manifest version. The values below are the resolved tokens from
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
// #d1d5db), so the result matches without the build step.
const _PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500 (inert controls)
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
onText: '#86efac', // text-green-300
};
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'transition:background-color .15s,color .15s;';
function _pcPill(label, title) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (title) b.title = title;
b.style.cssText = _PC_PILL;
// Hover is a pseudo-class we cannot express inline; these two
// listeners reproduce hover:bg-dark-500 for non-active pills only
// (an active pill keeps its tint on hover, as the other plugins do).
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
return b;
}
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
// when the active background style ignores the setting entirely (see
// _pcSync and _PC_USES) - the pill stays visible so the layout
// does not jump, but it is inert and says why on hover.
function _pcPaint(btn, on, disabled, reason) {
btn._on = !!on && !disabled;
btn.disabled = !!disabled;
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
// A toggle button must expose its state, not just its label.
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
// which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : '';
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
btn.style.opacity = disabled ? '.45' : '1';
btn.title = reason || 'React to the audio';
if (disabled) {
btn.style.backgroundColor = _PC_C.idle;
btn.style.color = _PC_C.textDim;
return;
}
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
btn.style.color = on ? _PC_C.onText : _PC_C.text;
}
function _pcGroupLabel(text) {
const el = document.createElement('div');
el.textContent = text;
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
return el;
}
// Pull every control back to what is actually stored. Runs on mount and
// whenever the settings bus reports one of our keys changed, so editing
// from the Settings page updates this control and vice-versa.
function _pcSync() {
// The active style is the EFFECTIVE one, not the stored one: while the
// Venue scene override is on it is what's mounted, and it ignores the
// whole Background group - picking a style writes `style` but
// _bgMountStyle resolves back to venue, so the dropdown would look
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
// and the user exits Venue from the visualization picker where they
// entered it. An unknown id enables everything rather than disabling
// it, so a style added without a _PC_USES row is merely unhelpful.
const venue = !!getVenueSceneOverride(); // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride() // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride()
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
const why = uses.why || 'This background style ignores this setting';
if (_pcReason) _pcReason.textContent = why;
// Point a screen reader at the reason, but only while a control is
// inert - cleared otherwise so an enabled control is not described by a
// stale reason.
const _pcDescribe = (el, inert) => {
if (!el) return;
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
else el.removeAttribute('aria-describedby');
};
_pcDescribe(_pcSel, venue);
_pcDescribe(_pcReactive, !uses.reactive);
_pcDescribe(_pcIntensity, !uses.intensity);
if (_pcSel) {
// The custom slots stay unselectable until something is uploaded -
// same rule settings.html applies.
const img = _pcSel.querySelector('option[value="image"]');
const vid = _pcSel.querySelector('option[value="video"]');
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
_pcSel.value = _bgReadGlobal('style');
// The dropdown still SHOWS the stored style (venue has no option),
// but it's inert while Venue owns the scene.
_pcSel.disabled = venue;
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
_pcSel.style.opacity = venue ? '.45' : '1';
_pcSel.style.cursor = venue ? 'not-allowed' : '';
// Restore the base tooltip when Venue exits — blanking it would
// permanently drop the mount-time 'Background style' hint. Matches
// how the intensity slider and Reactive pill restore theirs.
_pcSel.title = venue ? why : 'Background style';
}
if (_pcReactive) {
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why);
}
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
// enabled so the control's own title takes over.
if (_pcReactiveWrap) {
_pcReactiveWrap.title = uses.reactive ? '' : why;
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
}
if (_pcIntensity) {
_pcIntensity.value = String(_bgReadGlobal('intensity'));
_pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
}
if (_pcIntensityWrap) {
_pcIntensityWrap.title = uses.intensity ? '' : why;
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
}
}
// Mirror the current values into the Settings panel's controls when it's
// in the DOM.
//
// settings.html hydrates ONCE from localStorage when the panel is injected
// and never subscribes to the settings bus, so before this existed there
// was only one writer and it could not go stale. Adding the in-player
// picker made a second writer, and the panel had no way to hear about it —
// change the style mid-song and Settings would still show the old value.
//
// Assigning .value / .checked programmatically does NOT fire a 'change'
// event, so this cannot loop back into the setters.
function _pcSyncSettingsPanel() {
try {
const st = document.getElementById('h3d-bg-style');
if (st) st.value = _bgReadGlobal('style');
const re = document.getElementById('h3d-bg-reactive');
if (re) re.checked = !!_bgReadGlobal('reactive');
const inten = _bgReadGlobal('intensity');
const ie = document.getElementById('h3d-bg-intensity');
if (ie) ie.value = String(inten);
// The panel prints the numeric value beside the slider; keep its
// formatting identical to settings.html's own hydration.
const il = document.getElementById('h3d-bg-intensity-label');
if (il) il.textContent = Number(inten).toFixed(2);
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
}
function _pcMount() {
// A screen change can swap the popover out from under us, orphaning
// the control. Re-resolve only when the cached node is actually gone.
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
if (_pcEl) return true;
const slot = _pcSlot();
if (!slot) return false;
const box = document.createElement('div');
box.className = 'h3d-pc';
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
// Visually-hidden text carrying the "why greyed out" reason to screen
// readers; disabled controls point aria-describedby here. A title alone
// is announced unreliably and never on touch. One span suffices - every
// greyed control shares the same reason (derived from the single
// effective style).
_pcReason = document.createElement('span');
_pcReason.id = 'h3d-pc-reason';
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
box.appendChild(_pcReason);
box.appendChild(_pcGroupLabel('Background'));
// A dropdown, not pills: the style list is 8 entries and growing, and
// a pill per style dominated a popover whose other controls are single
// toggles. Styled to match the surrounding pills rather than left as a
// raw <select>.
_pcSel = document.createElement('select');
_pcSel.title = 'Background style';
_pcSel.setAttribute('aria-label', 'Background style');
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
for (const id of BG_STYLE_IDS) {
const o = document.createElement('option');
o.value = id;
o.textContent = _PC_LABELS[id] || id;
_pcSel.appendChild(o);
}
_pcSel.addEventListener('change', () => {
if (_pcSel.disabled) return; // inert under the Venue override
try { window.h3dBgSetStyle(_pcSel.value); }
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
});
box.appendChild(_pcSel);
const optWrap = document.createElement('div');
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
_pcReactive = _pcPill('Reactive', 'React to the audio');
_pcReactive.addEventListener('click', () => {
if (_pcReactive.disabled) return;
try { window.h3dBgSetReactive(!_pcReactive._on); }
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
});
optWrap.appendChild(_pcReactive);
box.appendChild(optWrap);
box.appendChild(_pcGroupLabel('Intensity'));
// Wrapper carries the reason on hover when the slider is disabled — a
// native-disabled <input> shows no title of its own.
_pcIntensityWrap = document.createElement('div');
_pcIntensityWrap.style.cssText = 'width:100%;';
_pcIntensity = document.createElement('input');
_pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
_pcIntensity.title = 'Background intensity';
_pcIntensity.setAttribute('aria-label', 'Background intensity');
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
// 'change' (fires on release), NOT 'input'. Every write goes through
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
// background style down and re-runs build(). On 'input' a single drag
// across the range would trigger ~20 full scene rebuilds on the main
// thread mid-playback. settings.html's slider makes the same choice:
// oninput only repaints its label, onchange calls the setter.
_pcIntensity.addEventListener('change', () => {
if (_pcIntensity.disabled) return;
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
});
_pcIntensityWrap.appendChild(_pcIntensity);
box.appendChild(_pcIntensityWrap);
slot.appendChild(box);
_pcEl = box;
_pcSync();
_pcListener = (key) => {
if (key === 'style' || key === 'reactive' || key === 'intensity'
|| key === 'customImageDataUrl' || key === 'customVideoName'
|| key === 'venueScene') {
// 'venueScene' has no dropdown/settings widget of its own, but
// toggling Venue changes the EFFECTIVE style, so the greying
// must re-evaluate (see _pcSync's effectiveStyle).
_pcSync();
_pcSyncSettingsPanel();
}
};
_bgSubscribe(_pcListener);
return true;
}
function _pcTeardownDom() {
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
}
function _pcAcquire() {
_pcRefs++;
_pcBindScreenHook();
if (_pcMount()) return;
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
// the renderer is viable inside the v3 player chrome, and player-chrome.js
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
// spinning it out to the ~3s budget for a slot that will never appear.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
// The rail popover may not be built yet on a cold load. Retry a few
// times, then give up quietly — Settings still works.
if (_pcRetryTimer) return;
_pcRetry = 0;
const tick = () => {
_pcRetryTimer = 0;
if (_pcRefs <= 0) return; // renderer went away mid-retry
// Re-attempt the bus subscription too, not just the mount. On a cold
// load the renderer can init before window.feedBack.on exists; the
// first _pcBindScreenHook() then no-ops and, without this, the hook
// never binds and the control goes permanently deaf to screen
// changes. Idempotent via the _pcScreenHook guard.
_pcBindScreenHook();
if (_pcMount()) return;
if (++_pcRetry > 12) return; // ~3s at 250ms
_pcRetryTimer = setTimeout(tick, 250);
};
_pcRetryTimer = setTimeout(tick, 250);
}
// Re-mount after the player chrome is rebuilt.
//
// _pcMount's isConnected check can only run when something calls it, and
// after the first successful mount nothing did - init() and the retry tick
// are the only callers, and the tick stops on success. So a popover that
// got swapped out left the control gone until the next song change. This
// listener gives that check a real trigger.
//
// Event-driven and cheap: one _pcMount() call per screen change, and it
// early-returns immediately when the cached node is still connected.
let _pcScreenHook = null;
function _pcBindScreenHook() {
if (_pcScreenHook) return;
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
try { bus.on('screen:changed', _pcScreenHook); }
catch (e) { _pcScreenHook = null; }
}
function _pcRelease() {
_pcRefs = Math.max(0, _pcRefs - 1);
if (_pcRefs > 0) return;
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
// Drop the screen:changed subscription too, not just the DOM. The
// refcount guard inside the hook makes a stale one harmless, but the
// listener and its closure would otherwise outlive the control for the
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
// again, binding another hook to the same bus while the old one stays.
// _pcBindScreenHook re-binds on the next acquire.
if (_pcScreenHook) {
try {
const bus = window.feedBack;
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
_pcScreenHook = null;
}
_pcTeardownDom();
}
return { _pcAcquire, _pcRelease };
}
+375
View File
@@ -0,0 +1,375 @@
// h3d-carve-9: W-section (camera lerp) — effectiveVfov + camUpdate.
// Cut 13 (S-section lookahead) extends this factory in place: same createCamera({…})
// destructure grows with additional DI params and returned symbols.
//
// DI surface (per §2 of the cut-9 contract):
// Constants (22) — BASE_VFOV, HORPLUS_*, CAM_LERP_BASE, CAM_H/DIST_BASE,
// 6× CAM_FRAME_*, FOCUS_D, S_GAP, K, 3× FRET_ROW_FIT_*,
// 4× CAM_TILT_*
// Getters (11) — getCam, getTgtX/Dist, getAspectScale, getLeftyCached,
// getNStr, getProbe, getTiltSmoothing, getPaneAspect/Uid,
// getHighwayCanvas
// Pairs (5) — getCurX/setCurX, getCurDist/setCurDist,
// getCurLookY/setCurLookY, getTgtLookY/setTgtLookY,
// getFretRowFitBoost/setFretRowFitBoost
// Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor,
// aspectRegisterPane
import { computeBPM, lowerBoundT, camBaseDistU, camLowFretPullbackU } from './geometry.js'; // h3d-carve-1,13
import { _ssActive } from './utils.js'; // h3d-carve-3
export function createCamera({
// ── Constants ──────────────────────────────────────────────────────────
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
CAM_FRAME_DIST_NEAR, CAM_FRAME_DIST_FAR,
CAM_FRAME_H_NEAR, CAM_FRAME_H_FAR,
CAM_FRAME_D_NEAR, CAM_FRAME_D_FAR,
FOCUS_D, S_GAP, K,
FRET_ROW_FIT_NDC_MIN, FRET_ROW_FIT_DEADBAND, FRET_ROW_FIT_BOOST_MAX,
CAM_TILT_BAND_T, CAM_TILT_BAND_C, CAM_TILT_STR_T, CAM_TILT_STR_C,
// ── Live-accessor getters ───────────────────────────────────────────────
getCam,
getTgtX, getTgtDist, getAspectScale, getLeftyCached,
getNStr, getProbe, getTiltSmoothing, getPaneAspect, getPaneUid,
getHighwayCanvas,
// ── Getter+setter pairs (write-backs) ──────────────────────────────────
getCurX, setCurX,
getCurDist, setCurDist,
getCurLookY, setCurLookY,
getTgtLookY, setTgtLookY,
getFretRowFitBoost, setFretRowFitBoost,
// ── Function refs ───────────────────────────────────────────────────────
sY, freeCamFor, aspectPaneKey, resolveTuneFor, aspectRegisterPane,
// h3d-carve-13: S-section lookahead — +4 const shorthand, +1 getter, +4 fn refs
NFRETS, CAM_LOOKAHEAD_MEASURES, CAM_LOOKAHEAD_SEC, CAM_FRET_EDGE_BLEND,
getMeasureStarts,
validString, getChartAnchorAt, xFretMid, xFret,
}) {
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
// VERBATIM MOVE. 0 beyond-subst: BASE_VFOV / HORPLUS_* / HORPLUS_MIN_VFOV
// are plain DI params in the factory destructure — no rewires needed.
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
// VERBATIM MOVE. DI rewires — 25 beyond-subst:
// Local aliases intro (10): cam, paneAspect, curX, curDist, curLookY,
// tgtLookY, _fretRowFitBoost, nStr, _probe, tiltSmoothing
// Fn-ref renames (4): _aspectPaneKey→aspectPaneKey,
// _aspectRegisterPane→aspectRegisterPane,
// _resolveTuneFor→resolveTuneFor, _freeCamFor→freeCamFor
// Direct getter calls (6): getPaneUid, getTgtX, getTgtDist,
// getAspectScale, getLeftyCached, getHighwayCanvas
// Write-back setter calls (5): setCurX, setCurDist, setCurLookY,
// setTgtLookY, setFretRowFitBoost
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _resolveTuneFor(paneKey) returns the shared base with THIS pane's
// overrides (if any) laid on top, so a single split pane can be framed
// independently. The base is seeded from defaults + localStorage on
// first read, so a persisted tuning session applies on load without
// opening the panel. Every field is finite-coerced. When disabled (or
// splitOnly and not in a split) the tune is treated as null, so
// effectiveVfov returns the base vertical fov and cam.fov is restored
// to it. The fov write is guarded on an actual change so a steady pane
// costs nothing.
const cam = getCam(); // DI rewire: live-accessor
const paneAspect = getPaneAspect(); // DI rewire: live-accessor (replaces _paneAspect)
const _paneKey = aspectPaneKey( // DI rewire: fn-ref rename
bundle && bundle.songInfo && bundle.songInfo.arrangement, getPaneUid()); // DI rewire: getPaneUid
// Only feed the Target-picker registry while the tuner is open (same
// gate as the readout). Closed → nothing is registered, so the registry
// can't grow for users who never open the panel; the key is still
// resolved below so any saved overrides keep applying.
if (window.__h3dAspectPanelOpen) aspectRegisterPane(_paneKey); // DI rewire: fn-ref rename
const _aspTune = resolveTuneFor(_paneKey); // DI rewire: fn-ref rename
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(paneAspect, _tune); // DI rewire: paneAspect
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a per-pane live readout for the tuner panel (only while it's
// open, so the steady path stays allocation-free). Keyed by pane so
// the panel can show the reading for whichever target is selected.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
_slot.aspect = paneAspect; _slot.vfov = _vfov; // DI rewire: paneAspect
_ro.__last = _paneKey;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
// Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = freeCamFor(getHighwayCanvas()); // DI rewire: fn-ref rename + getter
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && paneAspect > _startAspect) && !_dirActive; // DI rewire: paneAspect
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
// DI rewire: lerped state — read per-call into locals, mutate locally,
// write back via setters so screen.js closure vars stay in sync.
// (Never cached at factory init — buildBoard may reset these between frames.)
let curX = getCurX(); // DI rewire: local alias
curX += (getTgtX() - curX) * lerp; // DI rewire: getTgtX()
setCurX(curX); // write-back
let _fretRowFitBoost = getFretRowFitBoost(); // DI rewire: local alias
let curDist = getCurDist(); // DI rewire: local alias
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
curDist += (getTgtDist() * _fretRowFitBoost - curDist) * lerp; // DI rewire: getTgtDist()
setCurDist(curDist); // write-back
const dist = curDist * getAspectScale(); // DI rewire: getAspectScale()
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
// Zoom-interpolated framing multipliers: tight (NEAR) -> lower/closer;
// wide (FAR, fret 1<->20) -> higher/pulled back.
const _zt = Math.max(0, Math.min(1,
(dist - CAM_FRAME_DIST_NEAR) / (CAM_FRAME_DIST_FAR - CAM_FRAME_DIST_NEAR)));
const _hMul = CAM_FRAME_H_NEAR + (CAM_FRAME_H_FAR - CAM_FRAME_H_NEAR) * _zt;
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (getLeftyCached() ? -1 : 1) * 10 * K; // DI rewire: getLeftyCached()
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via the camera bridge:
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
// panel's own camera), falling back to the global window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
// _freeCam resolved above via freeCamFor(getHighwayCanvas()): the
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
let curLookY = getCurLookY(); // DI rewire: local alias (read before freeCam block)
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
const _yaw = Number.isFinite(_freeCam.yaw) ? _freeCam.yaw : 0;
const _tx = curX, _ty = curLookY, _tz = _lookAtZ; // look target
let _vx = _camX - _tx, _vy = _camY - _ty, _vz = _camZ - _tz;
_vx *= _distMul; _vy *= _distMul; _vz *= _distMul; // zoom (dolly)
_vy *= _heightMul; // height
const _cy = Math.cos(_yaw), _sy = Math.sin(_yaw); // orbit around Y
const _rx = _vx * _cy - _vz * _sy, _rz = _vx * _sy + _vz * _cy;
_camX = _tx + _rx; _camY = _ty + _vy; _camZ = _tz + _rz;
}
cam.position.set(_camX, _camY, _camZ);
// Self-correcting look-at Y: project the fretboard's near-edge centre
// to NDC space. If it drifts toward the frame edge, nudge tgtLookY
// toward the fretboard centre so the camera tilts to re-frame it.
// This lets the camera adapt to any panel aspect ratio automatically.
const nStr = getNStr(); // DI rewire: local alias
const _probe = getProbe(); // DI rewire: local alias
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
// Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35).
// The deadband width and correction strength are both blended
// between Twitchy and Calm bounds by the user's tiltSmoothing
// setting — twitchy = re-frame aggressively (narrow band, strong
// nudge); calm = let small drift ride (wide band, weak nudge).
const DESIRED_NDC_Y = -0.35;
const tiltSmoothing = getTiltSmoothing(); // DI rewire: local alias
const tiltBand = CAM_TILT_BAND_T + (CAM_TILT_BAND_C - CAM_TILT_BAND_T) * tiltSmoothing;
const tiltStr = CAM_TILT_STR_T + (CAM_TILT_STR_C - CAM_TILT_STR_T) * tiltSmoothing;
let tgtLookY = getTgtLookY(); // DI rewire: local alias
if (_probe.y < DESIRED_NDC_Y - tiltBand || _probe.y > DESIRED_NDC_Y + tiltBand) {
// _probe.y too low → fretboard near bottom → tgtLookY decreases → camera tilts down → fretboard rises
// _probe.y too high → fretboard near top → tgtLookY increases → camera tilts up → fretboard drops
const correction = (DESIRED_NDC_Y - _probe.y) * fretMidY * tiltStr;
tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction));
}
setTgtLookY(tgtLookY); // write-back
curLookY += (tgtLookY - curLookY) * lerp;
setCurLookY(curLookY); // write-back
// Final look-at with the corrected Y (overrides the tentative one above).
// User tilt (pitch) + pan offsets layer on top when the free-cam is
// enabled; each is coerced to a finite number to avoid a NaN look-at.
if (_freeCam && _freeCam.enabled) {
const _panX = Number.isFinite(_freeCam.panX) ? _freeCam.panX : 0;
const _panY = Number.isFinite(_freeCam.panY) ? _freeCam.panY : 0;
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
// Project the fret-number-row band (just below the lowest string, at
// the play line) with the final camera. If it sits below the safe
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
// curDist lerp target next frame) until it clears; relax lazily once
// there's comfortable headroom. Asymmetric + deadbanded so it
// converges without hunting, and capped so the zoom can't pop. It
// cooperates with the tilt loop above rather than fighting it: pulling
// back shrinks the scene, the tilt loop keeps the board centre anchored
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
// while the free-cam (Camera Director) owns the view.
if (_freeCam && _freeCam.enabled) {
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
} else {
cam.updateMatrixWorld();
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
_probe.set(curX, _rowY, 0.5 * K);
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
const _rowNdcY = _probe.y;
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
// Row below the safe line → pull back promptly, proportional to
// the deficit so it converges in a few frames without overshoot.
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
&& _fretRowFitBoost > 1) {
// Comfortable headroom → relax the dolly back toward normal, lazily.
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
}
}
setFretRowFitBoost(_fretRowFitBoost); // write-back
}
/* ── h3d-carve-13: S-section lookahead helpers ───────────────────── */
// VERBATIM MOVE from screen.js Region A (original lines 6630-6719).
// 1 beyond-subst: _measureStarts → getMeasureStarts() (live getter).
// lookaheadEndTime: factory-private (no external callers).
function lookaheadEndTime(now) {
const ms = getMeasureStarts(); // h3d-carve-13: _measureStarts → getMeasureStarts()
if (!ms || ms.length === 0) return now + CAM_LOOKAHEAD_SEC;
// Binary search: lo = first index with ms[lo] > now.
let lo = 0, hi = ms.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (ms[mid] <= now) lo = mid + 1; else hi = mid; }
const curIdx = lo - 1; // current measure (-1 if before the first)
const targetIdx = curIdx + CAM_LOOKAHEAD_MEASURES;
if (targetIdx >= 0 && targetIdx < ms.length) return ms[targetIdx];
// Past the last measure: extrapolate using the average measure duration.
if (ms.length >= 2) {
const avg = (ms[ms.length - 1] - ms[0]) / (ms.length - 1);
if (avg > 0) return ms[ms.length - 1] + (targetIdx - (ms.length - 1)) * avg;
}
return now + CAM_LOOKAHEAD_SEC;
}
// Earliest future chart time whose lookahead end reaches eventTime.
// lookaheadEndTime() is monotonic but measure-stepped, so a small
// bounded binary search works for both measure grids and the seconds
// fallback without duplicating/inverting its edge-case logic.
function lookaheadBootstrapTime(now, eventTime) {
if (!(eventTime > now) || lookaheadEndTime(now) >= eventTime) return now;
let lo = now;
let hi = eventTime;
for (let i = 0; i < 32; i++) {
const mid = (lo + hi) * 0.5;
if (lookaheadEndTime(mid) >= eventTime) hi = mid;
else lo = mid;
}
return hi;
}
function lookaheadComputeFretBounds(now, anchors, notes, chords) {
const tEnd = lookaheadEndTime(now);
let minF = 99;
let maxF = 0;
let any = false;
if (anchors && anchors.length) {
for (let tt = now; tt <= tEnd + 1e-9; tt += 0.125) {
const a = getChartAnchorAt(anchors, tt);
if (!a) continue;
let fStart = Math.round(Number(a.fret));
if (!Number.isFinite(fStart) || fStart < 1) fStart = 1;
let w = Number(a.width);
if (!Number.isFinite(w)) w = 4;
w = Math.max(1, Math.round(w));
const fHi = Math.min(NFRETS, fStart + w - 1);
minF = Math.min(minF, fStart);
maxF = Math.max(maxF, fHi);
any = true;
}
}
const consider = f => {
if (!(f > 0)) return;
minF = Math.min(minF, f);
maxF = Math.max(maxF, f);
any = true;
};
if (notes) {
let i = lowerBoundT(notes, now);
for (; i < notes.length; i++) {
const n = notes[i];
if (n.t > tEnd) break;
if (!validString(n.s)) continue;
consider(n.f);
}
}
if (chords) {
let i = lowerBoundT(chords, now);
for (; i < chords.length; i++) {
const ch = chords[i];
if (ch.t > tEnd) break;
if (!ch.notes) continue;
for (const cn of ch.notes) {
if (!validString(cn.s)) continue;
consider(cn.f);
}
}
}
if (!any || minF > maxF) return null;
return { minF, maxF };
}
function lookaheadTargetWorldX(minF, maxF) {
const wb = CAM_FRET_EDGE_BLEND;
const middle = (xFretMid(minF) + xFretMid(maxF)) * 0.5;
const weighted = 0.6 * xFret(0) + 0.4 * xFret(NFRETS);
return middle * (1 - wb) + weighted * wb;
}
return { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX };
}
+149
View File
@@ -0,0 +1,149 @@
// h3d-carve-8: Q-section partial — lighting/FX utilities.
// buildBoard stays in screen.js (deferred to plan §3 row 16, P-section cut)
// because its write-back surface spans 9 factory-scope vars that P/U/Y also
// own; premature extraction would require a ~50-param DI. See plans/highway3d-carve.md.
//
// DI surface (per-call live-accessors — never cache at factory init):
// BG_DEFAULTS, K — plain IIFE-scope constants
// getT — live-accessor (Three.js, lazy-loaded)
// getAmbLight/getDirLight — live-accessors (null until initScene)
// getCinematic/getTimingFx — live-accessors (toggle flags)
// getSparkPts/setSparkPts — live-accessor + setter (Points object, set by buildBoard)
// getSparkN/getSparkPos/… — live-accessors for particle buffers (element-mutated in place)
// getComposer/setComposer — getter+setter (lazy-assigned inside _bloomEnsure)
// getBloomLoad/setBloomLoad — getter+setter (Promise, assigned inside _bloomEnsure)
// getBloomPass/setBloomPass — getter+setter (pass object, assigned inside _bloomEnsure)
// getBloomW/setBloomW/H — getter+setter (dimensions, assigned inside _bloomEnsure)
// getRen/getScene/getCam/getHighwayCanvas — live-accessors (null until initScene)
// canvasSize — factory-scope function ref (stable)
export function createFx({
BG_DEFAULTS, K,
getT,
getAmbLight, getDirLight, getCinematic,
getTimingFx,
getSparkPts, setSparkPts, getSparkN,
getSparkPos, setSparkPos, getSparkVel, setSparkVel,
getSparkCol, setSparkCol, getSparkLife, setSparkLife,
getComposer, setComposer,
getBloomLoad, setBloomLoad,
getBloomPass, setBloomPass,
getBloomW, setBloomW, getBloomH, setBloomH,
getRen, getScene, getCam, getHighwayCanvas,
canvasSize,
}) {
function _h3dHexOrDefault(hexStr, defHex) {
// VERBATIM MOVE. BG_DEFAULTS from plain DI param.
const d = defHex || BG_DEFAULTS.nutColor;
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
? hexStr.trim().toLowerCase()
: d;
return parseInt(s.slice(1), 16);
}
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
// surround to pop against; strengthen the key light for modelling.
// Toggle via the 'cinematic' setting so it's directly comparable.
function _applyCinematic() {
// VERBATIM MOVE. DI rewire: ambLight/dirLight/_cinematic from live-accessors.
const ambLight = getAmbLight(), dirLight = getDirLight(), _cinematic = getCinematic();
if (!ambLight || !dirLight) return;
ambLight.intensity = _cinematic ? 0.45 : 0.85;
dirLight.intensity = _cinematic ? 1.15 : 0.8;
}
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
// late amber. Falls back to green when timing is unknown (pure-provider path).
function _timingHex(ts) {
// VERBATIM MOVE. DI rewire: _timingFx from getTimingFx().
if (!getTimingFx() || !ts || ts === 'OK') return 0x22ff88;
if (ts === 'EARLY') return 0x35d6ff;
if (ts === 'LATE') return 0xffb84d;
return 0x22ff88;
}
function _sparkBurst(x, y, z, hex, count) {
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call,
// not cached at factory init — buildBoard may reassign the refs).
const _sparkPts = getSparkPts();
if (!_sparkPts || count <= 0) return;
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
const _SPARK_N = getSparkN();
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
let made = 0;
for (let i = 0; i < _SPARK_N && made < count; i++) {
if (_sparkLife[i] > 0) continue;
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
}
}
function _sparkUpdate(dt) {
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call).
const _sparkPts = getSparkPts();
if (!_sparkPts) return;
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
const _SPARK_N = getSparkN();
const grav = 55 * K; let any = false;
for (let i = 0; i < _SPARK_N; i++) {
if (_sparkLife[i] <= 0) continue;
const j = i * 3;
_sparkLife[i] -= dt;
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
any = true;
_sparkVel[j + 1] -= grav * dt;
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
const fade = 1 - Math.min(1, dt * 3.2);
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
}
_sparkPts.geometry.attributes.position.needsUpdate = true;
_sparkPts.geometry.attributes.color.needsUpdate = true;
_sparkPts.visible = any;
}
// #4 Bloom — .then() body extracted for test harness reach (Toby r1 F1).
// Called with [EC, RP, UB, OP] when all four postprocessing modules resolve.
// DI rewire: T/ren/scene/cam/highwayCanvas from live-accessors (per-call).
function _applyBloom([EC, RP, UB, OP]) {
// VERBATIM MOVE of the .then() handler body from _bloomEnsure.
// DI rewire: T/ren/scene/cam/highwayCanvas read from live-accessors.
try {
const T = getT(), highwayCanvas = getHighwayCanvas();
const ren = getRen(), scene = getScene(), cam = getCam();
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
// survives the bloom path — EffectComposer's default target has no
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
// DPR1 displays that have no supersampling cushion).
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
const comp = new EC.EffectComposer(ren, _bloomRT);
comp.addPass(new RP.RenderPass(scene, cam));
const bp = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
setBloomPass(bp);
comp.addPass(bp);
comp.addPass(new OP.OutputPass());
comp.setSize(w, h);
setBloomW(w); setBloomH(h); setComposer(comp);
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); setComposer(null); }
}
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
// the composer once ready, or null (caller falls back to a direct render).
function _bloomEnsure() {
// VERBATIM MOVE. DI rewire: all factory-scope vars via get/set accessors.
// _composer, _bloomLoad, _bloomPass, _bloomW, _bloomH are REASSIGNED via
// _applyBloom — setters required; must NOT silently become locals.
if (getComposer()) return getComposer();
const ren = getRen(), scene = getScene(), cam = getCam();
if (getBloomLoad() || !ren || !scene || !cam) return null;
const A = '/static/vendor/three/addons/';
setBloomLoad(Promise.all([
import(A + 'postprocessing/EffectComposer.js'),
import(A + 'postprocessing/RenderPass.js'),
import(A + 'postprocessing/UnrealBloomPass.js'),
import(A + 'postprocessing/OutputPass.js'),
]).then(_applyBloom).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)));
return null;
}
return { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _applyBloom, _bloomEnsure };
}
+281
View File
@@ -0,0 +1,281 @@
/**
* Pure geometry helpers h3d-carve-1.
*
* All exports are stateless; they depend only on the compile-time constants
* below (which mirror their factory-scope counterparts in screen.js verbatim
* and never vary at runtime). No DOM, no Three.js imports, no side-effects.
*
* screen.js keeps a 1-arg delegator:
* const fretX = f => geoFretX(f, _h3dFretUniform);
* so no call site in screen.js changes.
*/
// ── Compile-time constants (mirror screen.js; never vary at runtime) ─────────
const SCALE = 2.25;
const K = SCALE / 300;
// Horizontal stretch factor for fret X positions.
const FRET_SCALE = SCALE * 1.1;
const NFRETS = 24;
/**
* Pure 12-semitone spacing compresses toward the bridge; multiply each
* segment above this fret by the factor so high positions stay
* slightly more playable/readable in 3D.
*/
const FRET_SPACING_STRETCH_ABOVE12 = 1.1;
const FRET_SPACING_ANCHOR_F = 12;
/** Note travel speed. */
const TS = 230 * K;
// ── Fret X ───────────────────────────────────────────────────────────────────
// Logarithmic spacing — mirrors real guitar fret geometry (12th root of 2).
const _fretXLog = f => {
if (f <= 0) return 0;
const raw = FRET_SCALE - FRET_SCALE / Math.pow(2, f / 12);
if (f <= FRET_SPACING_ANCHOR_F) return raw;
const rawAnchor = FRET_SCALE - FRET_SCALE / Math.pow(2, FRET_SPACING_ANCHOR_F / 12);
return rawAnchor + (raw - rawAnchor) * FRET_SPACING_STRETCH_ABOVE12;
};
// Uniform spacing — same column width per fret (chart-format style).
// Total board width equals the logarithmic NFRETS position for consistency.
const _fretXUniStep = _fretXLog(NFRETS) / NFRETS;
const _fretXUni = f => f <= 0 ? 0 : f * _fretXUniStep;
/**
* World-space X position for fret `f`.
* @param {number} f fret number
* @param {boolean} uniform true uniform (chart-format) spacing; false logarithmic
*/
export const geoFretX = (f, uniform) => uniform ? _fretXUni(f) : _fretXLog(f);
// ── Time → Z ─────────────────────────────────────────────────────────────────
/** Convert a time delta (seconds) to a world-space Z offset (notes travel toward Z). */
export const dZ = dt => -dt * TS;
// ── Slide trail ───────────────────────────────────────────────────────────────
/**
* Pitched slide uses `sl`, unpitched uses `slu` (slide-to vs unpitched slide fields).
* Prefer `sl` when both are present matches RS wire.
* @returns {{ endFret: number, unpitched: boolean } | null}
*/
export function slideTrailEnd(n) {
const sl = n.sl;
const slu = n.slu;
if (Number.isFinite(sl) && sl >= 0) {
return { endFret: sl | 0, unpitched: false };
}
if (Number.isFinite(slu) && slu >= 0) {
return { endFret: slu | 0, unpitched: true };
}
return null;
}
// ── Camera distance building blocks ──────────────────────────────────────────
// Camera tgtDist building blocks. Both the dynamic (camera-follow)
// and locked (frets 1-12) branches compose tgtDist from these, so
// any future tuning of the base zoom curve or low-fret pullback
// lands in both branches without drift.
// span — camDistMax - camDistMin in fret-span units
// minFret — lowest fretted note in the camera window (or 1 for
// the locked branch, which assumes nut chords)
export const camBaseDistU = span => 65 + Math.max(span, 4) * 3;
export const camLowFretPullbackU = minFret => Math.max(0, 5 - minFret) * 4;
// ── BPM estimation ────────────────────────────────────────────────────────────
export function computeBPM(beats, t) {
if (!beats || beats.length < 2) return 120;
let lo = 0, hi = beats.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (beats[mid].time < t) lo = mid + 1; else hi = mid;
}
let closest = lo;
if (lo === beats.length) closest = beats.length - 1;
else if (lo > 0 && Math.abs(beats[lo - 1].time - t) < Math.abs(beats[lo].time - t)) closest = lo - 1;
const start = Math.max(0, closest - 2);
const end = Math.min(beats.length - 1, closest + 2);
let sum = 0, count = 0;
for (let i = start; i < end; i++) {
const dt = beats[i + 1].time - beats[i].time;
if (dt > 0) { sum += dt; count++; }
}
return count > 0 && sum > 0 ? 60 / (sum / count) : 120;
}
// ── Render-order layer stack — h3d-carve-1b ───────────────────────────────────
export const RENDER_ORDER_LAYER_STACK = Object.freeze([
'CHORD_FILL',
'CHORD_STRUM_FILL',
'CHORD_STRUM_LINE',
'SUSTAIN_TRAIL',
'CHORD_FRAME',
'CHORD_EDGE_GLOW',
'CONNECTOR_LINE',
'FRET_COLUMN',
'ARP_CONNECTOR_LINE',
'NOTE_OUTLINE',
'NOTE_CORE',
'TECHNIQUE_MARKER',
'BOARD_STRING',
'BOARD_FRET_WIRE',
'NOTE_FRET_LABEL',
'ARP_NOTE_FRET_LABEL',
'CHORD_FRET_LABEL',
]);
export const RENDER_ORDER_LAYER_INDEX = Object.freeze(RENDER_ORDER_LAYER_STACK.reduce(
(indexByLayer, layerName, layerIndex) => {
indexByLayer[layerName] = layerIndex;
return indexByLayer;
},
Object.create(null)
));
export const RENDER_ORDER_AT_Z_ZERO = 700;
export const RENDER_ORDER_FAR_CLAMP = 50;
/**
* Computes renderOrder from world depth plus a named layer.
* Closer objects receive larger values and paint over farther objects; the
* layer stack breaks ties at the same depth, keeping labels above note gems.
*
* The layer index is added as a sub-unit fraction (< 1) so the integer
* depth bucket STRICTLY dominates: a farther object can never outrank a
* nearer one merely because it sits on a higher layer. Adding the raw index
* (0..N-1) directly would let the ~N-wide layer span leak across depth
* buckets and re-introduce far-over-near bleed for notes within ~N draw
* units of each other. Fraction granularity (1/N 0.06) stays well above
* the 0.0001 intra-element sub-increments used at some call sites.
*/
export function renderOrderForLayerAtZ(worldZ, layerName) {
const layerIndex = RENDER_ORDER_LAYER_INDEX[layerName];
if (layerIndex === undefined) throw new Error(`Unknown 3D highway depth layer: ${layerName}`);
const depthRenderOrder = Math.max(
RENDER_ORDER_FAR_CLAMP,
Math.round(RENDER_ORDER_AT_Z_ZERO + worldZ / K)
);
return depthRenderOrder + layerIndex / RENDER_ORDER_LAYER_STACK.length;
}
// ── Note key and binary search — h3d-carve-1b ─────────────────────────────────
// Fast integer key for (t, s) pairs — avoids per-frame string allocation in
// hot-path Set lookups. Encodes chart time in 0.1 ms steps (sufficient for
// chart-format note precision) combined with the string index.
// t range 0600 s → 06,000,000; * 10 + s(07) = max 60,000,007 < 2^53 ✓.
// The |0 truncates to int32 but the outer multiply stays in float64, so the
// key is always a safe JS integer for songs ≤ 214,748 s (well above any song).
export function _noteKey(t, s) { return ((t * 10000 + 0.5) | 0) * 10 + s; }
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
// Byte-identical to core's bundle.lowerBoundT — kept as a local because this
// plugin must run on downlevel hosts whose bundles don't carry the helper
// (it's called from ~30 sites incl. top-level helpers that don't receive a
// bundle). New code that already holds a bundle should prefer
// bundle.lowerBoundT / bundle.lowerBoundTime.
export function lowerBoundT(arr, t) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (arr[mid].t < t) lo = mid + 1;
else hi = mid;
}
return lo;
}
/**
* Return the chart time of the first fretted event that can still affect
* the camera at `now`, or the next fretted onset after it.
*
* This is intentionally a one-time full-chart scan. It runs only when a
* new song/arrangement's arrays first arrive, allowing the camera to frame
* the opening phrase during a silent intro instead of waiting for that
* phrase to enter the live targeting window. Open strings do not define a
* horizontal fret target, and malformed/out-of-range strings are ignored.
*
* Events already inside the behind-window, plus older sustains that are
* still ringing at `now`, return `now` so bootstrap framing matches the
* ordinary live path. Future events return their onset time.
*/
export function hwyFirstRelevantFrettedTime(notes, chords, now, behind, stringCount) {
const nStrings = Number.isFinite(stringCount) ? Math.max(0, Math.floor(stringCount)) : 0;
const cameraFloor = now - Math.max(0, Number(behind) || 0);
let first = Infinity;
const validFretted = n => n
&& n.f > 0
&& Number.isInteger(n.s)
&& n.s >= 0
&& n.s < nStrings;
const consider = (eventTime, sustain) => {
const t = Number(eventTime);
if (!Number.isFinite(t)) return;
const sus = Number(sustain);
const end = t + (Number.isFinite(sus) && sus > 0 ? sus : 0);
if (t < cameraFloor && end < now) return;
const relevantTime = t <= now ? now : t;
if (relevantTime < first) first = relevantTime;
};
if (notes) {
for (const n of notes) {
if (validFretted(n)) consider(n.t, n.sus);
}
}
if (chords) {
for (const ch of chords) {
if (!ch || !ch.notes) continue;
for (const cn of ch.notes) {
if (validFretted(cn)) consider(ch.t, cn.sus);
}
}
}
return Number.isFinite(first) ? first : null;
}
// ── Fret mid — h3d-carve-1b ───────────────────────────────────────────────────
/**
* World-space X of the midpoint of fret column f.
* f <= 0 nut-side sentinel (2K).
* screen.js keeps `const fretMid = f => geoFretMid(f, _h3dFretUniform);`
* so no call-site changes in screen.js.
* @param {number} f fret number
* @param {boolean} uniform true uniform spacing; false logarithmic
*/
export const geoFretMid = (f, uniform) => f <= 0 ? -2 * K : (geoFretX(f - 1, uniform) + geoFretX(f, uniform)) / 2;
// ── Gaussian bloom texture ────────────────────────────────────────────────────
// Build a horizontal gaussian DataTexture for the sustain-rail bloom effect.
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u0.5)²/σ²),
// peaking at 1.0 in the centre. With the default σ=0.28 the edges retain
// ~0.20 alpha (not fully transparent) — a deliberately soft, wide falloff
// so the additive bloom fades gradually rather than cutting off sharply.
// Power-of-two width keeps WebGL mipmapping happy.
export function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
const data = new Uint8Array(w * 4);
for (let i = 0; i < w; i++) {
const u = i / (w - 1);
const d = (u - 0.5) / sigma;
const v = Math.exp(-0.5 * d * d);
const a = Math.round(v * 255);
data[i * 4] = 255;
data[i * 4 + 1] = 255;
data[i * 4 + 2] = 255;
data[i * 4 + 3] = a;
}
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
// LinearFilter on both axes so the bloom plane interpolates smoothly
// when scaled — the default NearestFilter causes visible banding.
tex.magFilter = ThreeLib.LinearFilter;
tex.minFilter = ThreeLib.LinearFilter;
tex.needsUpdate = true;
return tex;
}
+670
View File
@@ -0,0 +1,670 @@
// src/materials.js — h3d-carve-6
// Material builders extracted from screen.js N-section.
//
// screen.js usage (factory scope, once per createFactory() invocation):
// const _techMatCache = new Map();
// const { txtMat, pinchHarmonicMat, naturalHarmonicMat,
// palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
// triMat, bendChevronMat, darkenHex, slideArrowMat,
// _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool } =
// createMaterialBuilders({
// getT, // () => T — live accessor (T is null before loadThree resolves)
// getTxtCache, // () => txtCache — live accessor (txtCache reassigned to {} on teardown)
// techMatCache, // stable const Map ref (screen.js factory-scope; teardown .values()/.clear())
// techMeshMatClones, // stable const Set ref (screen.js factory-scope; teardown .clear())
// });
//
// Surprises vs plan §4:
// • DI is 4 params, not just { getT } — declared before commit
// • _syncOpenStringPitchLabels cluster (20+ factory-scope deps) excluded from this cut
// • _techMatCache stays in screen.js factory scope so teardown can call .values()/.clear() directly
//
// Beyond-subst rewires (4):
// 1. Factory wrapper createMaterialBuilders({...})
// 2. T → const T = getT() at the top of each function that needs Three.js
// 3. txtCache[k] → const cache = getTxtCache(); cache[k]
// 4. _techMatCache → techMatCache / _techMeshMatClones → techMeshMatClones (DI param names)
// 5. _pmXSpriteMat, _fhXSpriteMat promoted to module closure (scope change only)
export function createMaterialBuilders({ getT, getTxtCache, techMatCache, techMeshMatClones }) {
// ── Private closure vars (were factory-scope lets in screen.js N-section) ─
let _pmXSpriteMat = null;
let _fhXSpriteMat = null;
// ── Text-sprite style presets ─────────────────────────────────────────────
// Each preset describes how a class of label is rasterised.
// Tweak per-class look here (font, outline color/width, source
// canvas size). `wide` toggles a long aspect ratio for multi-char
// labels (chord/section names, "↑1/2", "~~~").
//
// Knobs:
// font — full CSS font shorthand (weight + size + family)
// wideFont — same, used when caller passes wide=true
// srcH — source-canvas height in px (square; wide=4×).
// Keep power-of-two so WebGL1 / Three.js retain
// mipmaps + linear-mip-linear filtering — NPOT
// textures silently fall back to no-mipmap and
// shimmer at distance.
// stroke — outline color (null = no outline)
// strokeW — outline line-width in source-canvas px
// shadow — { color, blur, dx, dy } or null
const TXT_STYLES = {
// The two fret-number sets the user wants to pop hardest.
fretRow: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
noteFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Ghost-fret labels on the board projection: same weight/size/outline
// as noteFret, but uses textAlign='center'; textBaseline='middle'
// (the standard branch in txtMat) so the glyph is truly centred on
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
// intentionally NOT activated for this style — that path was designed
// for Sprites and shifts the canvas origin, which causes visible
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
ghostFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Chord names — gold script-style label, lighter outline keeps
// the colour readable.
chord: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Section banners ("Verse", "Chorus") — same as chord weight.
section: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
technique: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Open-string "0" label on the note body itself.
open: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
};
function txtMat(text, col, wide, style) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const sName = style || 'technique';
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
if (cache[k]) return cache[k];
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
const h = sp.srcH;
const str = String(text);
const font = wide ? sp.wideFont : sp.font;
let w = wide ? h * 4 : h;
if (!wide && sName === 'noteFret') {
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
// glyphs stay centred at (w/2, h/2) without edge clipping.
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const tw = probe.measureText(str).width;
let pad = 0;
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
if (sp.shadow) {
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
}
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d');
x.font = font;
// Fret / open-string digits: anchor from actualBoundingBox so the
// glyph sits at the true optical centre of the canvas (fixes
// sprites looking off-centre inside the board ghost and elsewhere).
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
// billboard. Sprites tolerate slight canvas off-centering because Three.js
// centres them at their world position; a Mesh does not — the digit lands
// wherever it sits in UV space. Use the advance-width centre as the initial
// pen position and then correct for any ink asymmetry via actualBoundingBox.
const inkCenterGhost = !wide && sName === 'ghostFret';
let drawX = w / 2;
let drawY = h / 2;
if (inkCenterFret) {
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
const m = x.measureText(str);
const L = m.actualBoundingBoxLeft;
const R = m.actualBoundingBoxRight;
const A = m.actualBoundingBoxAscent;
const D = m.actualBoundingBoxDescent;
if (
L != null && R != null && A != null && D != null &&
Number.isFinite(L) && Number.isFinite(R) &&
Number.isFinite(A) && Number.isFinite(D)
) {
const inkW = R - L;
drawX = (w - inkW) / 2 - L;
drawY = (h + A - D) / 2;
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
// small canvas nudge keeps sprites centred on the board ghost.
if (sName === 'noteFret') drawY -= h * 0.028;
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
drawX = w / 2;
drawY = h / 2;
}
} else if (inkCenterGhost) {
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
// stroke) to find the true ink centre of mass without contamination
// from the isotropic shadow blur. For Arial Black "1" the shadow from
// the thin upper-left flag bleeds leftward and cancels part of the
// rightward correction when we include it in the scan. Measuring fill
// alone isolates the actual glyph shape.
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
// Max 4 unique digits (14) → cache-miss runs at most 4 times ever.
x.textAlign = 'center';
x.textBaseline = 'middle';
try {
const tmpC = document.createElement('canvas');
tmpC.width = w; tmpC.height = h;
const tc = tmpC.getContext('2d');
tc.font = font;
tc.textAlign = 'center';
tc.textBaseline = 'middle';
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
// and muddles the centroid; fill alone gives the cleanest reading.
tc.fillStyle = '#ffffff';
tc.fillText(str, w / 2, h / 2);
const id = tc.getImageData(0, 0, w, h).data;
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
// correct perceptual centre rather than the geometric bbox midpoint.
let sumX = 0, sumY = 0, sumA = 0;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const a = id[(py * w + px) * 4 + 3];
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
}
}
if (sumA > 0) {
// shift pen so centroid → canvas centre, then add a small
// extra rightward nudge (8 %) so the vertical stroke of
// narrow digits like "1" sits visually at gem centre rather
// than the advance-width centre (which may be slightly left
// of the dominant ink mass for Arial Black numerals).
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
drawY = h / 2 + (h / 2 - sumY / sumA);
}
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
// x (real canvas) still has textAlign='center'; textBaseline='middle'
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
}
if (sp.shadow) {
x.shadowColor = sp.shadow.color;
x.shadowBlur = sp.shadow.blur;
x.shadowOffsetX = sp.shadow.dx;
x.shadowOffsetY = sp.shadow.dy;
}
if (sp.stroke && sp.strokeW > 0) {
x.lineJoin = 'round';
x.miterLimit = 2;
x.strokeStyle = sp.stroke;
x.lineWidth = sp.strokeW;
x.strokeText(str, drawX, drawY);
}
x.fillStyle = col;
x.fillText(str, drawX, drawY);
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
// depthTest:false means later geometry never *fails* depth
// against these sprites, but without depthWrite:false the
// sprites still write to the depth buffer (Three.js default
// is depthWrite:true even for SpriteMaterial). That can
// make subsequent sprites/labels vanish — match the
// pattern used by the other sprite materials in this file.
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
function pinchHarmonicMat(col) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const baseCol = new T.Color(col != null ? col : '#ffd84d');
// v5 — compact concentric ellipses:
// 1. black outer border rx=0.430h ry=0.255h
// 2. string-color body rx=0.418h ry=0.232h
// 3. black inner ring rx=0.407h ry=0.218h
// 4. string-color inner rx=0.264h ry=0.218h
// 5. black center dot rx=0.134h ry=0.120h
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
if (cache[k]) return cache[k];
const h = 512;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const TAU = Math.PI * 2;
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
x.clearRect(0, 0, h, h);
x.save();
x.translate(h / 2, h / 2);
// Form 1 — black outer border
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
// Form 2 — string-color main body
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
// Form 3 — black inner ring
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
// Form 4 — string-color inner spot (narrower)
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
// Form 5 — black center dot
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
x.restore();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
function naturalHarmonicMat() {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
if (cache[k]) return cache[k];
const h = 256;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const cx = h / 2;
const cy = h / 2;
const TAU = Math.PI * 2;
x.clearRect(0, 0, h, h);
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
glow.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = glow;
x.beginPath();
x.arc(cx, cy, h * 0.44, 0, TAU);
x.fill();
x.shadowColor = 'rgba(0,0,0,0.85)';
x.shadowBlur = 14;
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
x.beginPath();
x.arc(cx, cy, h * 0.31, 0, TAU);
x.fill();
// Punch out the inner gap so the icon reads as a bright ring.
x.shadowBlur = 0;
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(cx, cy, h * 0.20, 0, TAU);
x.fill();
x.globalCompositeOperation = 'source-over';
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
x.shadowBlur = 10;
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
x.lineWidth = 8;
x.beginPath();
x.arc(cx, cy, h * 0.255, 0, TAU);
x.stroke();
x.shadowColor = 'rgba(0,0,0,0)';
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
x.beginPath();
x.arc(cx, cy, h * 0.12, 0, TAU);
x.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.96,
});
cache[k] = mat;
return mat;
}
// Only two PM/FH variants exist (palm-mute = black-on-white,
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
// muted chord-note per frame, so dense PM/FH passages were paying
// for a string concat + Map lookup on every call. Hoist both
// SpriteMaterial refs and short-circuit before touching the cache.
// They're populated lazily on first use; teardown still reaches
// them via the shared ``txtCache`` because muteXMat writes there.
function palmMuteXSpriteMat() {
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
}
function fretHandMuteXSpriteMat() {
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
}
function muteXMat(fillCol, strokeCol) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
if (cache[k]) return cache[k];
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
// using outerW/2, which is too small). With the correct pad the white
// cap is fully inside the canvas and the border is visible at every tip.
const h = 512;
const outerW = 132, innerW = 114;
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
x.clearRect(0, 0, h, h);
x.lineCap = 'square';
// Draw each diagonal in its own stroke() call — caps of the two
// diagonals don't interact, and the white outer is drawn before the
// black inner so the border is clean at every edge and tip.
x.strokeStyle = strokeCol;
x.lineWidth = outerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
x.strokeStyle = fillCol;
x.lineWidth = innerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
// Technique-marker sprite materials (triangle / chevron). Keyed by a
// packed NUMBER, not a string — triMat/bendChevronMat are called from
// the drawNote hot path, so a string cache key would allocate per
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
// step-count) so triangle and chevron entries can't collide.
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down)
// with a thick border in the gem's string colour.
function triMat(up, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + (up ? 0 : 1);
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256, m = S * 0.15;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
g.closePath();
g.lineJoin = 'round';
g.fillStyle = '#ffffff';
g.fill();
g.lineWidth = S * 0.122;
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
// string colour (chart-format bend notation — 1 per half-step).
function bendChevronMat(steps, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.lineWidth = S * 0.10;
g.lineJoin = g.lineCap = 'round';
const padX = S * 0.18;
const rowH = S / steps;
const amp = Math.min(rowH * 0.55, S * 0.24);
for (let i = 0; i < steps; i++) {
const cy = (i + 0.5) * rowH;
g.beginPath();
g.moveTo(padX, cy + amp * 0.5);
g.lineTo(S / 2, cy - amp * 0.5);
g.lineTo(S - padX, cy + amp * 0.5);
g.stroke();
}
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
// marker — full string colour is too bright next to the gem.
function darkenHex(hex, factor) {
const h = (hex >>> 0) & 0xffffff;
const r = Math.round(((h >> 16) & 0xff) * factor);
const g = Math.round(((h >> 8) & 0xff) * factor);
const b = Math.round((h & 0xff) * factor);
return (r << 16) | (g << 8) | b;
}
// Slide-direction arrow (/): a filled triangle pointing toward the
// slide's destination fret, in the gem's (darkened) string colour.
// `hex` here is already the darkened colour — keep its own cache-key
// nibble range (8/9) so it can't collide with triMat (0/1) or
// bendChevronMat (3-6).
function slideArrowMat(pointRight, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 8 + (pointRight ? 0 : 1);
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256, m = S * 0.18;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
g.closePath();
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
g.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
function _meshMatForGhostFretDigit(spriteMat) {
const T = getT(); // beyond-subst: T via live accessor
let mb = spriteMat.userData.h3dGhostFretMeshMat;
if (!mb) {
mb = new T.MeshBasicMaterial({
map: spriteMat.map,
transparent: true,
depthTest: false,
depthWrite: false,
});
spriteMat.userData.h3dGhostFretMeshMat = mb;
}
return mb;
}
/**
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
* texture, so technique markers can be applied to a rotatable PlaneGeometry
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
*
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
* clone> so a recycled mesh that's used for several techniques
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
* clone for each one rather than disposing-and-recloning on every
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
* markers this collapses the per-frame allocation entirely while
* still being bounded the per-mesh Map has at most one entry per
* distinct technique × colour the mesh has ever been used for.
*/
function _spriteMat2MeshMat(mesh, sm) {
const T = getT(); // beyond-subst: T via live accessor
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
if (perMesh) {
const hit = perMesh.get(sm.map);
if (hit) return hit;
}
let base = sm.userData.h3dTechMeshMat;
if (!base) {
base = new T.MeshBasicMaterial({
map: sm.map,
transparent: true,
// depthTest: false — cross-note Z ordering is handled by
// per-note renderOrderForLayerAtZ(...) calls rather than the
// depth buffer. This is necessary because close notes often use
// mGlow (depthWrite:false), so the depth buffer can't reliably
// occlude far markers near the hit line. With per-note renderOrder,
// far labels render first and close note geometry renders last,
// appearing on top without depthTest.
depthTest: false,
depthWrite: false,
// forceSinglePass accompanies EVERY transparent DoubleSide
// material in this file: without it, Three r158+ renders
// each such object in TWO passes (back side then front),
// setting material.needsUpdate on both — which forces a
// full getParameters/program-cache lookup per object per
// frame (profiled at ~4% of throttled main-thread time)
// and doubles the draw calls. The two-pass path exists to
// fix self-occlusion sorting on closed transparent meshes;
// all our DoubleSide materials are flat unlit quads
// (labels, rails, frames, lanes) where it buys nothing.
side: T.DoubleSide, forceSinglePass: true,
});
sm.userData.h3dTechMeshMat = base;
}
// First conversion for this mesh: the pTechPlane pool factory gave
// it a placeholder MeshBasicMaterial that the caller is about to
// overwrite with the clone below. Dispose it now — once
// mesh.material is reassigned the placeholder is orphaned and
// teardown's scene.traverse() pass can no longer reach it, so it
// would leak one GPU material per pooled mesh for the renderer's
// lifetime.
if (!perMesh && mesh.material && mesh.material !== base) {
mesh.material.dispose?.();
}
if (!perMesh) {
perMesh = new Map();
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
}
const clone = base.clone();
perMesh.set(sm.map, clone);
techMeshMatClones.add(clone); // beyond-subst: _techMeshMatClones → techMeshMatClones
return clone;
}
function pool(parent, mk) {
const a = [];
let n = 0;
return {
get() {
if (n < a.length) {
const o = a[n++];
o.visible = true;
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
return o;
}
const o = mk(); parent.add(o); a.push(o); n++; return o;
},
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
// Pre-allocate `cap` slots at construction so the first dense
// playback frames don't pay the new-Mesh allocation cost
// mid-RAF (felt as a stall on 7/8-string charts where the
// visible-note count outruns the lazy-grow path). Lazy growth
// past `cap` still works — this is amortisation, not a cap.
//
// Coerce `cap` to a non-negative int32: a float would still
// work but a callsite passing `Infinity` (or `NaN`) would
// otherwise spin the while-loop until OOM. `cap | 0`
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
// Math.max(0, …) keeps negatives out.
warm(cap) {
// Local rename to avoid shadowing the pool's outer
// `n` (the in-use index advanced by get() / reset()).
const targetLen = Math.max(0, cap | 0);
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
return this;
},
};
}
return {
txtMat, pinchHarmonicMat, naturalHarmonicMat,
palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat,
_meshMatForGhostFretDigit, _spriteMat2MeshMat, pool,
};
}
File diff suppressed because it is too large Load Diff
+924
View File
@@ -0,0 +1,924 @@
// src/overlay.js — h3d-carve-7
// Lyrics + HUD overlay extracted from screen.js O-section (lines 43385209
// post-cut-6). Pure 2D canvas drawing — no Three.js dependency.
//
// screen.js usage (factory scope, once per createFactory() invocation):
// const { drawChordDiagram, _drawDiagramCached, drawSectionHud,
// drawToneHud, drawLyrics } = createOverlay({
// diagRenderCache, // stable const Map ref (screen.js factory-scope)
// // teardown calls .clear() on it directly; the shared
// // ref makes that visible to the overlay without a callback
// });
//
// Surprises vs contract:
// • longestConsecutiveRun (lines 43384352) co-moved — only called by drawChordDiagram
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX moved here and DELETED from
// screen.js lines 573575 (only used inside O-section)
// • _DIAG_CACHE_MAX moved inside createOverlay body and deleted from
// screen.js factory scope (line 3256) — same factory-scope level, different file
//
// Beyond-subst rewires (2):
// 1. Factory wrapper createOverlay({ diagRenderCache })
// 2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
// ── Constants moved from screen.js module scope (lines 573575) ─────────────
// Only users were inside the O-section; no other callers remain in screen.js.
const DIAG_SIZE_MIN = 0.08;
const DIAG_SIZE_MAX = 0.16;
const DIAG_CELL_MAX = 34;
export function createOverlay({ diagRenderCache }) {
// ── Moved from screen.js factory scope (line 3256) ───────────────────────
// Cap chosen to cover the ~56 active chord shapes per phrase while
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
// panel at typical 1920×1080). A structural fix — caching a
// tightly-sized box surface instead of the full overlay canvas —
// is tracked as a follow-up.
const _DIAG_CACHE_MAX = 6;
// ── Lyrics layout cache — moved from factory scope (line 5077) ───────────
// measureText per syllable + row wrapping only changes when the displayed
// line(s), font size, or canvas width change, not per frame.
let _lyrRowsCache = null;
// Returns indices of the longest consecutive run in a sorted integer
// array as { start, len } — `sorted[start..start+len)` is the run.
// Avoids the two per-call sub-array allocations of the previous
// implementation (best + cur arrays grown via .push), at the cost
// of one small 2-key result object. Net: callers in the chord-
// diagram render path no longer churn arrays per visible chord.
function longestConsecutiveRun(sorted) {
let bestStart = -1, bestLen = 0;
let curStart = -1, curLen = 0;
for (let i = 0; i < sorted.length; i++) {
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
if (curLen === 0) curStart = i;
curLen++;
} else {
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
curStart = i; curLen = 1;
}
}
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
return { start: bestStart, len: bestLen };
}
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
function drawChordDiagram(ctx, opts) {
const {
name, frets,
opacity = 1,
entranceT = 1.0,
canvasW = 600, canvasH = 400,
inverted = false,
sizeSlider = 0.5,
position = 'tl',
nStr = 6,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Responsive sizing — CELL derived from panel height + user slider.
// COLS is the resolved string count from the caller (via resolveStringCount)
// so bass (4), extended (7/8) arrangements render correctly.
const COLS = nStr, ROWS = 4;
// Minimum column span required for PATH B (bracket extension / detection).
// Math.min(COLS-1, 4) scales with string count:
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
// Maps diagram column index → chord-template frets-array index.
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
// startFret / isFirstPos must be known before CELL so that fretLabelW
// can be measured and factored into the width cap. The old
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
// extra left padding reserved for non-first-position "Nfr" labels.
const playedFrets = frets.filter(f => f > 0);
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
const startFret = Math.max(1, minFret);
const isFirstPos = startFret === 1;
// Phase 1 — height + hard-cap estimate, used only to size the label font.
// Cap against the vertical space available below lyricsBottom so that the
// diagram does not overflow into the lyrics banner on short split panels with
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
// and 'br' the full canvas height is available.
// Clamp to at least 1 so font/box calculations never receive 0-px input
// on very short panels (e.g. tiny split cells < 44 px tall).
const isTopCorner = position === 'tl' || position === 'tr';
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
const cellEst = Math.max(1, Math.min(
Math.round(availH * sizeF / (ROWS + 3)),
DIAG_CELL_MAX,
));
// Extra left padding for the "Nfr" label on non-first-position chords.
// Measured with ctx.measureText at cellEst so the estimate is exact.
let fretLabelW = 0;
if (!isFirstPos) {
// Measure inside a save/restore so this font assignment does not
// leak to the caller (the outer ctx.save() happens after CELL is derived).
ctx.save();
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
ctx.restore();
}
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
// Two width constraints are needed because PAD has a hard floor of 6:
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
// Both are included so boxW ≤ canvasW in every regime.
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
const CELL = Math.max(1, Math.min(
cellEst,
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
));
const HEADER = Math.round(CELL * 1.6);
const MARKER = Math.round(CELL * 0.7);
const DOT_R = CELL * 0.3;
const PAD = Math.max(6, Math.round(CELL * 0.65));
const gridW = CELL * (COLS - 1);
const gridH = CELL * ROWS;
const PAD_L = PAD + fretLabelW;
const boxW = gridW + PAD_L + PAD;
const boxH = HEADER + MARKER + gridH + PAD;
// Anchor to chosen corner. Top positions get extra vertical offset
// to clear the timeline plugin and song name displayed at the top.
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
// how many wrap lines the current panel width produces.
const E = PAD;
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Guard: the canvasHboxH clamp above can push `by` above lyricsBottom when
// wrapped lyrics consume nearly the full panel height. This applies to ALL
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
// still reach up into the lyrics banner on very short or narrow panels where
// boxH is larger than the space below the lyrics. In those cases skip drawing
// entirely rather than painting on top of the lyrics banner.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
// Ease-out quadratic entrance scale: 0.85 → 1.0.
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
ctx.save();
ctx.globalAlpha = opacity;
if (scale !== 1.0) {
const cx = bx + boxW / 2, cy = by + boxH / 2;
ctx.translate(cx, cy);
ctx.scale(scale, scale);
ctx.translate(-cx, -cy);
}
// Background + border.
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
const root = rootMatch ? rootMatch[1] : name;
const quality = rootMatch ? rootMatch[2] : '';
const rootSize = Math.round(CELL * 1.25);
const qualSize = Math.round(rootSize * 0.65);
ctx.textBaseline = 'middle';
const nameY = by + HEADER * 0.55;
ctx.font = `bold ${rootSize}px sans-serif`;
const rootW = ctx.measureText(root).width;
ctx.font = `${qualSize}px sans-serif`;
const qualW = quality ? ctx.measureText(quality).width : 0;
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
ctx.fillStyle = '#e8d080';
ctx.font = `bold ${rootSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(root, nameStartX, nameY);
if (quality) {
ctx.font = `${qualSize}px sans-serif`;
ctx.fillStyle = 'rgba(232,208,128,0.75)';
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
}
// Nut: CELL-proportional filled rect + subtle highlight line.
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
// the smallest diagrams (CELL=1 on compact split panels).
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
if (isFirstPos) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
}
// Fret label for non-first-position chords.
if (!isFirstPos) {
ctx.fillStyle = 'rgba(220,200,120,0.9)';
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
}
// Fret lines.
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
ctx.beginPath();
ctx.moveTo(gx, gy + r * CELL);
ctx.lineTo(gx + gridW, gy + r * CELL);
ctx.stroke();
}
// String lines with varying weight: low E heavier, high e lighter.
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
// Weights scale with CELL so strings never bleed into adjacent columns on
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
for (let col = 0; col < COLS; col++) {
const strIdx = getStrIdx(col);
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(gx + col * CELL, gy);
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
ctx.stroke();
}
// Barre detection — two complementary paths:
//
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
// Bracket is initially set to the consecutive run's own endpoints (not the full
// startFretCols range) so isolated bass notes at the same fret can't pull the
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
//
// PATH B (full-span barre / extension):
// When PATH A fired: extend the bracket outward to the full outer startFret span
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
// columns is fretted (f > 0).
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
// where only the two outermost strings sit at startFret. An additional check
// ensures that no intermediate column is itself at startFret — this rules out
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
// incorrectly) while still catching B-major-style shapes where the barre
// finger covers only the outer two strings.
//
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Examples (6-string, MIN_BARRE_SPAN=4):
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
const startFretCols = [];
for (let col = 0; col < COLS; col++) {
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
}
const barreRun = longestConsecutiveRun(startFretCols);
let hasBarreArc = barreRun.len >= 2; // PATH A
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
if (startFretCols.length >= 2) { // PATH B
const minC = startFretCols[0];
const maxC = startFretCols[startFretCols.length - 1];
if (maxC - minC >= MIN_BARRE_SPAN) {
let allFretted = true;
for (let col = minC; col <= maxC; col++) {
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
}
if (allFretted) {
if (hasBarreArc) {
// PATH A fired: always safe to extend to full outer span.
barreMinCol = minC;
barreMaxCol = maxC;
} else {
// PATH A did not fire: only draw a bracket when no intermediate
// column sits at startFret. Intermediate startFret columns would
// indicate a scattered/alternating voicing rather than a clean
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
let noInnerAtStartFret = true;
for (let col = minC + 1; col < maxC; col++) {
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
}
if (noInnerAtStartFret) {
hasBarreArc = true;
barreMinCol = minC;
barreMaxCol = maxC;
}
}
}
}
}
if (hasBarreArc) {
const barreY = gy + CELL * 0.5;
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
// Straight bracket: a horizontal line with short vertical end caps.
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
ctx.stroke();
}
// Open/muted markers + finger dots.
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
for (let col = 0; col < COLS; col++) {
const f = frets[getStrIdx(col)];
const sx = gx + col * CELL;
const markerY = gy - MARKER * 0.5;
if (f < 0) {
const r = CELL * 0.20;
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
} else if (f === 0) {
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
} else {
const row = f - startFret;
if (row >= 0 && row < ROWS) {
const isBarreCol = hasBarreArc && f === startFret &&
col >= barreMinCol && col <= barreMaxCol;
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = Math.min(4, CELL * 0.4);
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
ctx.beginPath();
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
}
}
}
ctx.restore();
return boxH;
}
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
// transform is identity) the diagram is rendered once to an
// OffscreenCanvas and reused every subsequent frame via drawImage +
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
// the scale transform is non-trivial so we fall through to a fresh
// render — that window is ~12 frames at 60 fps, negligible.
//
// Returns boxH (diagram card height in px) so the draw loop can
// accumulate per-corner stack offsets when multiple overlays share
// the same corner position.
function _drawDiagramCached(ctx, opts) {
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
if (opacity <= 0) return 0;
if (entranceT < 1.0) {
return drawChordDiagram(ctx, opts) || 0;
}
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
let entry = diagRenderCache.get(key);
if (!entry) {
let oc;
try { oc = new OffscreenCanvas(canvasW, canvasH); }
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
if (diagRenderCache.size >= _DIAG_CACHE_MAX) {
diagRenderCache.delete(diagRenderCache.keys().next().value);
}
entry = { oc, boxH };
diagRenderCache.set(key, entry);
}
ctx.save();
ctx.globalAlpha = opacity;
ctx.drawImage(entry.oc, 0, 0);
ctx.restore();
return entry.boxH;
}
// Two-line section card. Top line is "Now: <current>", bottom line
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
// current vs upcoming — earlier single-line variant rendered both
// states with the same word and was confusing during playback.
//
// Returns boxH on draw, 0 when nothing rendered. Position / size
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
// anchor corners, sizeSlider in [0,1] scales card height.
//
// Hidden when:
// - no sections array, or
// - playback has not yet reached the first section AND there's
// no upcoming-only fallback rendered (we still show "Up Next"
// during the pre-roll so the user sees what's coming).
function drawSectionHud(ctx, opts) {
const {
sections, currentTime,
canvasW, canvasH,
position = 'tr',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
if (!sections || !sections.length) return 0;
// sections are time-ordered server-side; single forward scan.
let curIdx = -1;
for (let i = 0; i < sections.length; i++) {
if (sections[i].time <= currentTime) curIdx = i;
else break;
}
const cur = curIdx >= 0 ? sections[curIdx] : null;
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
// Pre-first-section: nothing playing yet but next is coming —
// still useful to render "Up Next" alone so the user gets the
// anticipatory cue during the song's intro silence.
if (!cur && !next) return 0;
const nowName = cur ? cur.name : '';
// Render countdown as a separate span so it can take a calmer
// grey-white treatment while the section name itself stays
// cyan. Combining them into one string would inherit the cyan
// fill across both, defeating the visual hierarchy promised
// in the FR.
let nextName = '';
let nextCountdown = '';
if (next) {
const dt = next.time - currentTime;
nextName = next.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
// Per-text-element scale applied to nameSize / tagSize / lineH
// when the unscaled card would overflow a narrow panel
// (splitscreen quad layout, ultra-tall portrait). Computed
// below from the measured contentW vs the available width.
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_NOW = 'Now:';
const TAG_NEXT = 'Up Next:';
// Phase-1 measurement at the unscaled font sizes — used to
// decide whether textScale needs to drop, and to lay out the
// final draw at whatever scale we land on.
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagNowWBase = ctx.measureText(TAG_NOW).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
// Target width budget: cap at canvasW - 16 and reserve PAD_X
// either side. If contentWBase exceeds the budget, scale the
// font proportionally — clamped to 0.55 so labels stay legible
// even on extreme split-panel widths.
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
// Phase-2 re-measurement at the scaled font sizes for the
// final layout. measureText doesn't scale linearly with font
// size on every glyph, so re-measuring is cheaper than
// multiplying the base widths by textScale and risking a
// half-pixel overflow.
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagNowW = ctx.measureText(TAG_NOW).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineNowW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Suppress overlap with the wrapped lyrics banner regardless
// of corner. Bottom-corner cards on short panels can still
// reach up into the banner once boxH exceeds the space below
// the lyrics — same shape the chord diagram uses.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
// Layout each line with tag left-aligned, name in cyan after a
// small gap. Both lines share the same x origin (bx + PAD_X)
// so the tag column visually aligns vertically.
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#00cccc';
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (nowName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NOW, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
// Tone-change HUD — card showing the active tone and the next upcoming
// tone with a countdown. Mirrors drawSectionHud's layout contract
// (position, size slider, lyricsBottom) but uses an amber accent colour
// so it reads as distinct from the cyan section card.
function drawToneHud(ctx, opts) {
const {
toneChanges, toneBase = '',
currentTime,
canvasW, canvasH,
position = 'tl',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Resolve active tone: toneBase before all changes, else the most
// recent change at or before currentTime.
// toneChanges items use { t, name } (not { time, name }) — both
// the legacy import path (server.py xml_tone_changes) and the sloppak
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
let curName = toneBase;
let nextChange = null;
if (toneChanges && toneChanges.length) {
for (let i = 0; i < toneChanges.length; i++) {
if (toneChanges[i].t <= currentTime) {
curName = toneChanges[i].name;
} else {
nextChange = toneChanges[i];
break;
}
}
}
if (!curName && !nextChange) return 0;
let nextName = '';
let nextCountdown = '';
if (nextChange) {
const dt = nextChange.t - currentTime;
nextName = nextChange.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider;
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_CUR = 'Tone:';
const TAG_NEXT = 'Next:';
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagCurWBase = ctx.measureText(TAG_CUR).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagCurW = ctx.measureText(TAG_CUR).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const curNameW = curName ? ctx.measureText(curName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineCurW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (curName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_CUR, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) {
const lines = [];
let line = null, word = null;
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
for (let i = 0; i < lyrics.length; i++) {
const l = lyrics[i];
const raw = l.w || '';
const endsLine = raw.endsWith('+');
const continuesWord = raw.endsWith('-');
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
if (!word) word = [];
word.push(l);
line.end = Math.max(line.end, l.t + l.d);
if (!continuesWord) flushWord();
if (endsLine) flushLine();
}
flushLine();
lyrics._lines = lines;
}
const allLines = lyrics._lines;
if (!allLines.length) return 0;
let currentIdx = -1;
for (let i = 0; i < allLines.length; i++) {
if (allLines[i].start <= currentTime) currentIdx = i;
else break;
}
if (currentIdx === -1) {
if (allLines[0].start - currentTime > 2.0) return 0;
currentIdx = 0;
}
const currentLine = allLines[currentIdx];
const nextLine = allLines[currentIdx + 1] || null;
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
const linesToShow = [currentLine];
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04;
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`;
let rows, spaceWidth, bgWidth;
const _lc = _lyrRowsCache;
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
&& _lc.shown === linesToShow.length
&& _lc.fontSize === fontSize && _lc.W === W) {
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
} else {
spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
}
if (row.length) rows.push(row);
}
bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
_lyrRowsCache = {
lyricsRef: lyrics, idx: currentIdx,
shown: linesToShow.length, fontSize, W,
rows, spaceWidth, bgWidth,
};
}
const rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath();
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
ctx.lineTo(bx + br, by + totalHeight);
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
ctx.lineTo(bx, by + br);
ctx.quadraticCurveTo(bx, by, bx + br, by);
ctx.closePath();
ctx.fill();
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
let xPos = W / 2 - rowWidth / 2;
const yPos = lineY + r * rowHeight + 2;
for (const w of row) {
for (const part of w.parts) {
const l = part.syl;
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
const isPast = currentTime >= l.t + l.d;
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
ctx.fillText(part.text, xPos, yPos);
xPos += part.width;
}
xPos += spaceWidth;
}
}
// Return the actual bottom Y of the rendered background box so callers
// (e.g. drawChordDiagram) can avoid overlapping it.
return Math.round(by + totalHeight);
}
return {
drawChordDiagram,
_drawDiagramCached,
drawSectionHud,
drawToneHud,
drawLyrics,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
// h3d-carve-10: K-section (score FX) extracted from screen.js.
// VERBATIM-MOVE: function bodies are byte-for-byte identical to their
// screen.js originals except for 7 DI-rewires (see inline // DI: comments).
// No logic changes, no new guards, no structural additions.
//
// Beyond-subst changes (all mechanical DI rewires):
// 1. _fxSpawnPop: _ndFrameNowMs → getNdFrameNowMs()
// 2. drawScoreFx: cam → aliased const cam = getCam()
// 3. drawScoreFx: _probe → aliased const _probe = getProbe()
// 4. drawScoreFx: _ndFrameNowMs → getNdFrameNowMs()
// 5. drawScoreFx: nStr → getNStr()
// 6. drawScoreFx: curX → getCurX()
// 7. fxInit: highwayCanvas (closure) → getHighwayCanvas()
export function createScoreFx({ getHighwayCanvas, getNdFrameNowMs, getCam, getProbe, getNStr, getCurX, sY }) {
// ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ──
// Two channels: (1) per-note "+N" score pops, sourced from the
// note-state provider's new { points, mult, popKey } fields at the
// moment a gem's verdict lands; (2) session-level bursts/pulses from
// the new `notedetect:fx` event (streak milestones, multiplier tier
// changes, streak breaks). Everything renders on the 2D overlay
// canvas (same layer as drawNotedetectLabels) — no Three.js objects,
// no txtMat() cache entries, nothing to dispose. Pools are fixed-
// size slot arrays created once per factory instance; when all slots
// are busy a new effect is simply dropped.
const _FX_POP_LIFE_MS = 700;
const _FX_BURST_LIFE_MS = 900;
const _FX_BURST_N = 36;
const _fxPops = Array.from({ length: 24 }, () => (
{ active: false, x: 0, y: 0, z: 0, bornMs: 0, text: '', mult: 1 }
));
const _fxBursts = Array.from({ length: 4 }, () => ({
active: false, bornMs: 0,
px: new Float32Array(_FX_BURST_N), py: new Float32Array(_FX_BURST_N),
vx: new Float32Array(_FX_BURST_N), vy: new Float32Array(_FX_BURST_N),
}));
// popKey -> expiry ms. Dedupes pops (chord members share the chord's
// popKey; sustains keep returning points for the whole glow window).
const _fxSeen = new Map();
let _fxOnFx = null; // notedetect:fx listener (window)
let _fxOnSkin = null; // notedetect:skin bus listener
// Generation counter: bumped by teardown() so the deferred window-
// copy fallback (a zero-delay task the listener removal can't cancel)
// bails instead of re-arming ring/burst state after teardown — or,
// worse, leaking a stale event into a subsequent init's fresh state.
let _fxGen = 0;
let _fxLastFxDetail = null; // reference dedup: window + instanceRoot dispatches share one detail
// Details seen via element-scoped (bubbled) dispatch. A WeakSet, not a
// single slot: one judged hit can emit several fx in the same task
// (milestone + multiplier tier-up), and the deferred window-copy
// fallback for the FIRST must still see that its element copy arrived
// after the SECOND overwrote any last-detail slot. GC reclaims
// entries once notedetect drops the detail objects.
let _fxElemSeen = new WeakSet();
let _fxRingMs = -1e9; // multiplier ring-pulse anchor
let _fxRingMult = 1;
let _fxBreakMs = -1e9; // streak-break flicker anchor
// Canvas-side palette per notedetect skin (mirrors the accents in
// notedetect's assets/plugin.css; fonts are document-loaded by that
// stylesheet so the overlay canvas can use the family names).
const _FX_PALETTES = {
neon: { accent: '#00f0ff', accent2: '#ff2ec4', miss: '#ff4444', font: 'Orbitron' },
esports: { accent: '#e8b43a', accent2: '#f5f5f4', miss: '#f87171', font: 'Rajdhani' },
metal: { accent: '#ffb347', accent2: '#ff6b35', miss: '#ef4444', font: 'Russo One' },
};
let _fxPalette = _FX_PALETTES.neon;
function _fxResolvePalette() {
let skin = null;
try { skin = localStorage.getItem('feedBack_notedetect_skin'); } catch (e) {}
_fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon;
}
function _fxSpawnPop(popKey, points, mult, x, y, z) {
if (_fxSeen.has(popKey)) return;
const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs
_fxSeen.set(popKey, nowMs + 4000);
for (let i = 0; i < _fxPops.length; i++) {
const p = _fxPops[i];
if (p.active) continue;
p.active = true;
p.x = x; p.y = y; p.z = z;
p.bornMs = nowMs;
p.text = '+' + points;
p.mult = mult || 1;
return;
}
}
function _fxSpawnBurst(nowMs) {
for (let i = 0; i < _fxBursts.length; i++) {
const b = _fxBursts[i];
if (b.active) continue;
b.active = true;
b.bornMs = nowMs;
for (let j = 0; j < _FX_BURST_N; j++) {
const a = (j / _FX_BURST_N) * Math.PI * 2;
const sp = 2 + (j % 5) * 0.8;
b.px[j] = 0; b.py[j] = 0;
b.vx[j] = Math.cos(a) * sp;
b.vy[j] = Math.sin(a) * sp - 1.2;
}
return;
}
}
function _fxHandle(d) {
// Reference dedup — notedetect dispatches the SAME detail object
// on window and on its instanceRoot; whichever arrives first wins.
if (d === _fxLastFxDetail) return;
_fxLastFxDetail = d;
const nowMs = performance.now();
if (d.fxType === 'milestone') {
_fxSpawnBurst(nowMs);
} else if (d.fxType === 'multiplier' && d.mult > (d.prevMult || 1)) {
_fxRingMs = nowMs;
_fxRingMult = d.mult;
} else if (d.fxType === 'streakBreak') {
_fxBreakMs = nowMs;
}
}
// Score FX overlay pass — "+N" pops rising off their gems, milestone
// particle bursts / multiplier ring-pulses / streak-break flickers
// anchored on the strike line. Same overlay layer + projection
// pattern as drawNotedetectLabels; costs one early-out when nothing
// is active.
function drawScoreFx(ctx, W, H) {
const cam = getCam(); // DI: cam
const _probe = getProbe(); // DI: _probe
if (!cam || !_probe) return;
const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs
// TTL-prune the pop dedup keys (bounded: only notes hit in the
// last few seconds).
if (_fxSeen.size) {
for (const [k, exp] of _fxSeen) {
if (exp <= nowMs) _fxSeen.delete(k);
}
}
let anyPop = false;
for (let i = 0; i < _fxPops.length; i++) {
if (_fxPops[i].active) { anyPop = true; break; }
}
let anyBurst = false;
for (let i = 0; i < _fxBursts.length; i++) {
if (_fxBursts[i].active) { anyBurst = true; break; }
}
const ringAge = nowMs - _fxRingMs;
const breakAge = nowMs - _fxBreakMs;
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) return;
const pal = _fxPalette;
ctx.save();
// Streak-break flicker: brief red wash over the whole panel.
if (breakAge < 350) {
const a = 0.10 * (1 - breakAge / 350);
ctx.fillStyle = pal.miss;
ctx.globalAlpha = a;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// Strike-line center in screen px — anchor for bursts + pulses.
let cx = W / 2, cy = H * 0.72, centerOk = false;
{
const fretMidY = (sY(0) + sY(getNStr() - 1)) / 2; // DI: nStr
_probe.set(getCurX(), fretMidY, 0); // DI: curX
_probe.project(cam);
if (_probe.z >= -1 && _probe.z <= 1) {
cx = (_probe.x * 0.5 + 0.5) * W;
cy = (-_probe.y * 0.5 + 0.5) * H;
centerOk = true;
}
}
// Multiplier ring-pulse: one expanding ring on tier-up; the ×4
// tier pulses in the secondary accent like the HUD badge.
if (centerOk && ringAge < 600) {
const t = ringAge / 600;
const ease = 1 - Math.pow(1 - t, 2);
ctx.beginPath();
ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
ctx.strokeStyle = _fxRingMult >= 4 ? pal.accent2 : pal.accent;
ctx.globalAlpha = 0.6 * (1 - t);
ctx.lineWidth = 3;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Milestone bursts.
if (anyBurst && centerOk) {
for (let i = 0; i < _fxBursts.length; i++) {
const b = _fxBursts[i];
if (!b.active) continue;
const age = nowMs - b.bornMs;
if (age >= _FX_BURST_LIFE_MS) { b.active = false; continue; }
const t = age / _FX_BURST_LIFE_MS;
ctx.globalAlpha = 1 - t;
for (let j = 0; j < _FX_BURST_N; j++) {
b.px[j] += b.vx[j];
b.py[j] += b.vy[j];
b.vy[j] += 0.08;
ctx.fillStyle = (j & 1) ? pal.accent : pal.accent2;
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
}
ctx.globalAlpha = 1;
}
}
// "+N" pops: rise off the gem and fade over the back half.
if (anyPop) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < _fxPops.length; i++) {
const p = _fxPops[i];
if (!p.active) continue;
const age = nowMs - p.bornMs;
if (age >= _FX_POP_LIFE_MS) { p.active = false; continue; }
_probe.set(p.x, p.y, p.z);
_probe.project(cam);
if (_probe.z < -1 || _probe.z > 1) continue;
const t = age / _FX_POP_LIFE_MS;
const sx = (_probe.x * 0.5 + 0.5) * W;
const sy2 = (-_probe.y * 0.5 + 0.5) * H - t * 30;
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
ctx.font = `bold ${13 + (p.mult - 1) * 2}px '${pal.font}', sans-serif`;
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.strokeText(p.text, sx, sy2);
ctx.fillStyle = pal.accent;
ctx.fillText(p.text, sx, sy2);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
// detail object twice in the same task: first explicitly on
// window (unscoped), then as a bubbling CustomEvent from its
// per-panel instanceRoot (scoped). Element-targeted copies are
// authoritative — accept only the ones whose root lives in this
// panel's container. The window copy is DEFERRED a task: by the
// time it runs, the element copy (same detail reference) has
// either arrived — making the window copy a duplicate to drop —
// or it never will (detector root not attached to the DOM), in
// which case the window copy is the compat fallback. This keeps
// splitscreen panels from rendering each other's FX even for
// the first event of a session.
//
// NOTE (verbatim-preserved): fxInit has NO re-entry guard —
// the original screen.js init block had none. Calling fxInit()
// twice double-registers the notedetect:fx listener. The dispatch
// contract says to declare, not fix, this behavior here.
function fxInit() {
_fxResolvePalette();
_fxOnFx = (e) => {
const d = e && e.detail;
if (!d) return;
const t = e.target;
if (t && t.parentElement) {
_fxElemSeen.add(d);
if (!getHighwayCanvas() || !t.parentElement.contains(getHighwayCanvas())) return; // DI: highwayCanvas
_fxHandle(d);
return;
}
const gen = _fxGen;
setTimeout(() => {
if (gen !== _fxGen) return; // torn down (or re-inited) meanwhile
if (_fxElemSeen.has(d)) return;
_fxHandle(d);
}, 0);
};
window.addEventListener('notedetect:fx', _fxOnFx);
if (window.feedBack && typeof window.feedBack.on === 'function'
&& typeof window.feedBack.off === 'function') {
_fxOnSkin = () => _fxResolvePalette();
window.feedBack.on('notedetect:skin', _fxOnSkin);
}
}
function fxTeardown() {
if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; }
if (window.feedBack && typeof window.feedBack.off === 'function') {
if (_fxOnSkin) { try { window.feedBack.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; }
}
for (const p of _fxPops) p.active = false;
for (const b of _fxBursts) b.active = false;
_fxSeen.clear();
_fxGen++; // invalidate any pending deferred window-copy fallbacks
_fxLastFxDetail = null;
_fxElemSeen = new WeakSet();
_fxRingMs = _fxBreakMs = -1e9;
}
function fxClearSeen() { _fxSeen.clear(); }
return { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx, fxClearSeen };
}
+78
View File
@@ -0,0 +1,78 @@
// h3d-carve-11: R-section (string glow) extracted from screen.js.
// VERBATIM-MOVE: updateStringHighlights body is byte-for-byte identical to
// screen.js except for 7 DI-rewires at function entry (aliased locals).
// No logic changes, no new guards.
//
// Beyond-subst changes (all mechanical DI rewires at function entry):
// 1. glowMul → aliased: const glowMul = getGlowMul()
// 2. _vibrancyIdleOp → aliased: const _vibrancyIdleOp = getVibrancyIdleOp()
// 3. _venueSceneOverride → aliased: const _venueSceneOverride = getVenueSceneOverride()
// 4. nStr → aliased: const nStr = getNStr()
// 5. stringLines → aliased: const stringLines = getStringLines()
// 6. mGlow → aliased: const mGlow = getMGlow()
// 7. mAccentCore → aliased: const mAccentCore = getMAccentCore()
// VENUE_GEM_EMISSIVE_MUL is a plain const shorthand (no aliasing needed).
// Total DI params: 8 (1 plain const + 7 live getters, 0 setter pairs).
export function createStringGlow({
VENUE_GEM_EMISSIVE_MUL,
getGlowMul,
getVibrancyIdleOp,
getVenueSceneOverride,
getNStr,
getStringLines,
getMGlow,
getMAccentCore,
}) {
function updateStringHighlights(noteState) {
// DI: all mutable IIFE-scope vars aliased here; body is verbatim.
const glowMul = getGlowMul(); // DI: glowMul
const _vibrancyIdleOp = getVibrancyIdleOp(); // DI: _vibrancyIdleOp
const _venueSceneOverride = getVenueSceneOverride(); // DI: _venueSceneOverride
const nStr = getNStr(); // DI: nStr
const stringLines = getStringLines(); // DI: stringLines
const mGlow = getMGlow(); // DI: mGlow
const mAccentCore = getMAccentCore(); // DI: mAccentCore
// Glow slider scales both the idle floor and anticipation peak,
// so glowMul=0 fully silences the per-string emissive pulse.
// Vibrancy controls the idle opacity floor — anticipation
// still rides on top regardless of vibrancy so play-feedback
// through the opacity channel survives even at glowMul=0.
//
// Folded with the post-noteState mGlow / mAccentCore writes
// (was a separate `for (s = 0; s < nStr)` loop in update()),
// so the per-string scratch arrays stay hot in L1 across all
// material writes for a given string.
const BASE_GLOW = 0.02 * glowMul;
const MAX_GLOW = 3.5 * glowMul;
const IDLE_OP = _vibrancyIdleOp;
const g = glowMul;
const venueGemMul = _venueSceneOverride ? VENUE_GEM_EMISSIVE_MUL : 1;
for (let s = 0; s < nStr; s++) {
const mesh = stringLines[s];
if (mesh) {
const intensity = Math.max(
noteState.stringSustain[s] ? 1 : 0,
noteState.stringAnticipation[s] || 0,
);
mesh.material.emissiveIntensity = BASE_GLOW + intensity * MAX_GLOW;
mesh.material.opacity = IDLE_OP + intensity * (1 - IDLE_OP);
mesh.scale.set(1, 1 + intensity * 0.3, 1 + intensity * 0.3);
}
// Hit-note emissive — same write pattern as the standalone
// loop that previously lived at update()'s post-call site.
// The glow slider scales it here since this assignment
// stomps anything _applyGlow() set statically.
const bg = noteState.strGlow[s] * g;
if (mGlow[s]) mGlow[s].emissiveIntensity = bg * venueGemMul;
if (mAccentCore[s]) {
mAccentCore[s].emissiveIntensity =
(bg + noteState.accentFillBoost[s] * g) * venueGemMul;
}
}
}
return { updateStringHighlights };
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Three.js lazy loader h3d-carve-2.
*
* Memoised singleton: the first `loadThree()` call kicks off the import;
* every subsequent call returns the same promise. `T` is a live-binding
* export so the IIFE in screen.js sees the updated reference after the
* promise resolves without any explicit getter call.
*
* Falls back to jsdelivr CDN when the local vendor copy is unavailable
* (air-gapped / static-file layout mismatch / dev origin).
*/
// ── URL constants (mirrors screen.js A-section; never vary at runtime) ──────
const THREE_URL = '/static/vendor/three/three.module.min.js';
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';
// ── Memoised loader ───────────────────────────────────────────────────────────
/** Live binding — updated to the Three.js module namespace on first load. */
export let T = null;
let threeLoadPromise = null;
export function loadThree() {
if (!threeLoadPromise) {
threeLoadPromise = import(THREE_URL)
.then(mod => { T = mod; return mod; })
.catch(() => import(THREE_CDN)
.then(mod => { T = mod; return mod; })
.catch(e => {
console.error('[3D-Hwy] Three.js load failed:', e);
threeLoadPromise = null;
throw e;
}));
}
return threeLoadPromise;
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Color utilities, tuning helpers, and splitscreen predicates h3d-carve-3.
*
* All exports are pure functions or compile-time constants; none capture
* factory-scope state. NSTR and MAX_RENDER_STRINGS are compile-time copies
* of the matching IIFE constants (both = 6 for the current 6-entry
* PALETTES.default / S_COL layout).
*/
// ── Compile-time IIFE constant ────────────────────────────────────────────────
// NSTR: standard guitar default (not a palette ceiling — safe as a fixed fact).
// MAX_RENDER_STRINGS is NOT duplicated here; it is the authority of S_COL.length
// in screen.js and flows in via the maxStrings parameter of resolveStringCount
// and _openStringPitchLabelsForTuning. screen.js keeps 1-line delegators that
// supply MAX_RENDER_STRINGS so zero call-sites change.
const NSTR = 6;
// ── Color utilities ───────────────────────────────────────────────────────────
/**
* Parse a CSS hex color string ('#rrggbb', '#rgb', or bare variants) to a
* packed 0xRRGGBB integer. Returns null on any parse failure.
*/
export function _h3dHexToInt(hex) {
if (typeof hex !== 'string') return null;
const t = hex.trim().replace(/^#/, '');
const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return parseInt(full, 16);
}
export function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); }
export function _darkenInt(hex, factor) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor);
}
export function _lightenInt(hex, t) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t);
}
// ── String-count resolution ───────────────────────────────────────────────────
/**
* Resolve the string count for the active arrangement. Prefer
* bundle.stringCount (exposed by feedBack core since #93 derived from
* notes/chords/tuning, works for 5-string bass, 7- and 8-string guitar).
* Falls back to arrangement-name detection for older feedBack cores.
* Clamped to MAX_RENDER_STRINGS so a malformed bundle doesn't index past
* the per-string material arrays.
*/
export function resolveStringCount(bundle, maxStrings) {
const sc = bundle && bundle.stringCount;
if (Number.isFinite(sc) && sc >= 1) {
return Math.min(Math.trunc(sc), maxStrings);
}
return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR;
}
// ── Tuning / pitch-label helpers ──────────────────────────────────────────────
/** Chart-format tuning entries are semitone offsets from instrument standard. */
export const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Open-string MIDI (thick → thin), matched to RS string index 0 low.
export const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]);
export const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]);
export const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]);
export const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]);
// F#/B/E standard extension — low string is a fifth below RS 7-string low B.
export const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]);
export function _baseOpenStringMidis(sc, arrangement) {
const isBass = /bass/i.test(arrangement || '');
if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice();
if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4);
if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice();
if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5);
if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice();
if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice();
if (Number.isFinite(sc) && sc > 8) {
const out = Array.from(_BASE_OPEN_MIDI_GUITAR8);
let last = out[out.length - 1];
while (out.length < sc) {
last += 5;
out.push(last);
}
return out.slice(0, sc);
}
const g6 = _BASE_OPEN_MIDI_GUITAR6.slice();
if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc);
return g6;
}
export function _midiToPitchLabel(midi) {
const m = Math.round(midi);
const octave = Math.floor(m / 12) - 1;
const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12];
return n + octave;
}
/**
* @param {object} bundle Highway render bundle (tuning, capo, stringCount)
* @param {object} songInfo WS song_info blob (arrangement, tuning, capo)
* @param {number} nEffective String count clamped like nStr / resolveStringCount
*/
export function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective, maxStrings) {
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), maxStrings) : resolveStringCount(bundle, maxStrings);
// bundle first: chart-transform substitutes tuning/capo there, while
// songInfo keeps the chart's originals by contract. A malformed
// (non-array) bundle.tuning falls back to songInfo instead of
// blanking the labels.
let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning);
let cap = bundle.capo;
cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0);
if (!Array.isArray(tuning)) tuning = [];
const base = _baseOpenStringMidis(n, songInfo?.arrangement);
const labels = [];
for (let s = 0; s < n; s++) {
const offRaw = tuning[s];
const off = Number.isFinite(offRaw) ? offRaw : 0;
const midi = (base[s] !== undefined ? base[s] : 40) + off + cap;
labels.push(_midiToPitchLabel(midi));
}
return labels;
}
// ── Splitscreen predicates ────────────────────────────────────────────────────
// window.feedBackSplitscreen is read live each call — never captured at
// module or factory scope.
export function _ssActive() {
const ss = window.feedBackSplitscreen;
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
return typeof ss.isCanvasFocused === 'function'
&& typeof ss.onFocusChange === 'function'
&& typeof ss.offFocusChange === 'function';
}
export function _ssIsCanvasFocused(highwayCanvas) {
const ss = window.feedBackSplitscreen;
if (!_ssActive()) return true;
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
ss.isCanvasFocused(highwayCanvas));
}
@@ -0,0 +1,548 @@
// Player-chrome background control.
//
// The control mounts a Background picker (style / Reactive / Intensity) into
// the player's Plugin Controls popover so the background can be changed
// mid-song. Two things about it are easy to get wrong and invisible when they
// are:
//
// * It is REFCOUNTED. Several renderer instances can be live at once (a
// splitscreen host creates one per panel), but the settings it writes are
// global — N controls would be N ways to set one value, and a leaked
// refcount pins a dead control in the UI. The multi-instance behaviour is
// exercised here with stubbed instances; it is NOT verified against a real
// splitscreen session, whose visualizer does not currently work.
// * It GREYS OUT controls the active style ignores. Not every background
// style reads `intensity`, and none of them read audio bands under
// Butterchurn, so a live-looking knob that does nothing is a real bug.
//
// h3d-carve-5: the _pc* block was moved from screen.js to src/bg-control.js.
// load() now evaluates the factory module (stripping the `export` keyword),
// calls createBgControl({DI}) with stubbed deps, and injects test-only getters
// into the return object so the private state vars remain observable.
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 SCREEN_JS = path.join(__dirname, '..', 'screen.js');
const BG_CONTROL_JS = path.join(__dirname, '..', 'src', 'bg-control.js');
// What each style is expected to consume, derived by reading the BG_STYLES
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
// table, which would only assert that the table equals itself.
// intensity: true => the style's build() reads settings.intensity
// reactive: true => the style's update() dereferences its `bands` argument
// 'butterchurn' is a mode, not a BG_STYLES fog-scenery entry: _bcSyncMode
// owns its controller and drives its own audio tap + canvas opacity (only
// the fog-scenery half falls through to BG_STYLES.off), so both are false.
const EXPECTED_USES = {
off: { intensity: false, reactive: false },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
image: { intensity: true, reactive: false },
video: { intensity: false, reactive: false },
butterchurn: { intensity: false, reactive: false },
};
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video'];
// Minimal DOM: only what the control touches.
function makeDom() {
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.parentNode = null;
this.listeners = {};
this.style = { cssText: '' };
this.disabled = false;
this._on = false;
}
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
removeChild(c) {
const i = this.children.indexOf(c);
if (i >= 0) this.children.splice(i, 1);
c.parentNode = null;
return c;
}
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
setAttribute(k, v) { this[k] = v; }
removeAttribute(k) { delete this[k]; }
get isConnected() {
let n = this;
while (n.parentNode) n = n.parentNode;
return n === root;
}
querySelector(sel) {
const m = /^option\[value="(.+)"\]$/.exec(sel);
const want = m ? m[1] : null;
const walk = (n) => {
for (const c of n.children) {
if (want != null && c.tagName === 'OPTION' && c.value === want) return c;
const r = walk(c);
if (r) return r;
}
return null;
};
return walk(this);
}
fire(type) { (this.listeners[type] || []).forEach((fn) => fn()); }
}
const root = new El('root');
const slot = new El('div');
root.appendChild(slot);
return { El, root, slot };
}
function load({ store: initialStore } = {}) {
// h3d-carve-5: load from src/bg-control.js (factory module) instead of
// slicing screen.js. Strip `export` for vm eval; inject test-only getters
// into the return object so private _pc* state vars remain observable.
const bgSrc = fs.readFileSync(BG_CONTROL_JS, 'utf8');
const stripped = bgSrc.replace(/^export\s+/gm, '');
// Augment the factory's return with accessor getters for private state so
// all existing test assertions (api.el, api.sel, api.refs, ...) keep working.
const instrumented = stripped.replace(
/return\s*\{\s*_pcAcquire\s*,\s*_pcRelease\s*\}/,
'return { _pcAcquire, _pcRelease,'
+ ' get el() { return _pcEl; },'
+ ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } }',
);
assert.notEqual(instrumented, stripped, 'return-augmentation anchor not found in bg-control.js');
const dom = makeDom();
const store = Object.assign({
style: 'particles',
reactive: true,
intensity: 0.5,
customImageDataUrl: '',
customVideoName: '',
}, initialStore);
const bus = {};
const listeners = new Set();
const emit = (key) => { for (const fn of listeners) fn(key); };
const writes = [];
const timers = [];
// DI dependencies as vm-globals. Tests mutate sandbox._venueSceneOverride
// directly; getVenueSceneOverride in the factory call reads it via the vm global.
const sandbox = {
console,
BG_STYLE_IDS,
_venueSceneOverride: false,
_bgReadGlobal: (key) => store[key],
_bgSubscribe: (fn) => listeners.add(fn),
_bgUnsubscribe: (fn) => listeners.delete(fn),
setTimeout: (fn) => { timers.push(fn); return timers.length; },
clearTimeout: () => {},
document: {
createElement: (t) => new dom.El(t),
// The Settings-panel mirror looks these up; absent here so it no-ops.
getElementById: () => null,
},
window: {
feedBack: {
uiVersion: 'v3', // _pcSlot gates on this (docs/plugin-v3-ui.md)
ui: { playerControlSlot: () => dom.slot },
// The real bus is an EventTarget wrapper exposing on/off. Modelled
// here so the screen:changed subscription — and its removal — are
// observable.
on: (ev, fn) => { (bus[ev] || (bus[ev] = [])).push(fn); },
off: (ev, fn) => {
const l = bus[ev];
if (!l) return;
const i = l.indexOf(fn);
if (i >= 0) l.splice(i, 1);
},
},
h3dBgSetStyle: (v) => { writes.push(['style', v]); store.style = v; emit('style'); },
h3dBgSetReactive: (v) => { writes.push(['reactive', v]); store.reactive = v; emit('reactive'); },
h3dBgSetIntensity: (v) => { writes.push(['intensity', v]); store.intensity = v; emit('intensity'); },
},
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
// Step 1: define createBgControl in the vm context.
vm.runInContext(instrumented, sandbox);
// Step 2: call the factory; DI values are vm-globals so the call names them directly.
const api = vm.runInContext(
'createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,'
+ ' getVenueSceneOverride: () => _venueSceneOverride })',
sandbox,
);
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
const screenHooks = () => (bus['screen:changed'] || []).length;
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
}
// Slice the real _bgReadSetting + _bgReadGlobal out of screen.js and run them
// against a localStorage stub. The main suite stubs both helpers identically,
// so it can't tell the #2 refactor from a no-op; this one proves the actual
// helper bodies differ where they must: _bgReadGlobal ignores a per-panel
// override that _bgReadSetting(panelKey, ...) still honours.
test('_bgReadGlobal reads the global slot, ignoring per-panel overrides', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const rgStart = src.indexOf(' function _bgReadSetting(panelKey, key) {');
const rgEnd = src.indexOf(' // Shared "stored string -> bool" coercion');
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'could not slice the read helpers');
const block = src.slice(rgStart, rgEnd);
const storage = new Map();
const sandbox = {
localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null) },
_bgCoerce: (_key, v) => v, // identity: we test key resolution, not coercion
_bgMemFallback: Object.create(null),
BG_DEFAULTS: { style: 'particles' },
};
sandbox.globalThis = sandbox;
const api = vm.runInNewContext(block + '\n({ _bgReadSetting, _bgReadGlobal, _bgMemFallback })', sandbox);
storage.set('h3d_bg_style', 'lights'); // global
storage.set('h3d_bg_panel3_style', 'geometric'); // a per-panel override
// The renderer, reading with a panel key, honours the per-panel override...
assert.equal(api._bgReadSetting('panel3', 'style'), 'geometric');
// ...but the shared control's global read must NOT see it - this is the
// whole point of #2 (previously _bgReadSetting(null, ...) relied on
// 'h3d_bg_null_style' never existing).
assert.equal(api._bgReadGlobal('style'), 'lights');
// In-memory staged value wins over the persisted global (matches
// _bgReadSetting's precedence).
api._bgMemFallback.style = 'aurora';
assert.equal(api._bgReadGlobal('style'), 'aurora');
delete api._bgMemFallback.style;
// Nothing stored -> BG_DEFAULTS.
assert.equal(api._bgReadGlobal('style'), 'lights');
storage.delete('h3d_bg_style');
assert.equal(api._bgReadGlobal('style'), 'particles');
});
test('mounts one control into the player-control slot', () => {
const { api, dom } = load();
api._pcAcquire();
assert.equal(dom.slot.children.length, 1);
assert.ok(api.sel, 'style dropdown was not created');
assert.equal(api.sel.children.length, BG_STYLE_IDS.length, 'one option per style');
});
test('multiple renderer instances share a single control', () => {
const { api, dom } = load();
api._pcAcquire();
api._pcAcquire();
api._pcAcquire();
api._pcAcquire();
assert.equal(dom.slot.children.length, 1, 'four instances must not mount four controls');
assert.equal(api.refs, 4);
api._pcRelease();
api._pcRelease();
api._pcRelease();
assert.equal(dom.slot.children.length, 1, 'still held by the last instance');
api._pcRelease();
assert.equal(dom.slot.children.length, 0, 'last release must unmount');
assert.equal(api.el, null);
});
test('binds the screen hook on a retry when the bus was not ready at acquire', () => {
const ctl = load();
// Cold load: on a fresh page the renderer can init before the event bus is
// wired AND before the rail popover exists. Simulate both being absent.
const savedOn = ctl.sandbox.window.feedBack.on;
const savedUi = ctl.sandbox.window.feedBack.ui;
delete ctl.sandbox.window.feedBack.on;
ctl.sandbox.window.feedBack.ui = {}; // no playerControlSlot -> mount fails
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 0, 'nothing to bind to yet');
assert.equal(ctl.api.el, null, 'no slot yet, so nothing mounted');
// Bus + slot come online; the retry tick must bind the hook, not only mount.
ctl.sandbox.window.feedBack.on = savedOn;
ctl.sandbox.window.feedBack.ui = savedUi;
ctl.timers.shift()(); // run one retry tick
assert.equal(ctl.screenHooks(), 1, 'the retry tick failed to bind the screen hook');
assert.ok(ctl.api.el, 'and it should have mounted too');
ctl.api._pcRelease();
});
test('the last release unbinds the screen:changed hook', () => {
const ctl = load();
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 1, 'acquire should subscribe once');
ctl.api._pcAcquire();
ctl.api._pcRelease();
assert.equal(ctl.screenHooks(), 1, 'a partial release must keep the hook');
ctl.api._pcRelease();
assert.equal(ctl.screenHooks(), 0, 'the hook outlived the control');
// And re-acquiring must re-subscribe exactly once, not zero times (the
// bind is guarded on _pcScreenHook, so failing to null it would leave the
// control permanently deaf to chrome rebuilds).
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 1, 're-acquire did not re-subscribe');
ctl.api._pcRelease();
});
test('teardown unsubscribes from the settings bus', () => {
const ctl = load();
ctl.api._pcAcquire();
assert.equal(ctl.listenerCount(), 1);
ctl.api._pcRelease();
assert.equal(ctl.listenerCount(), 0, 'listener leaked after unmount');
});
test('tracks changes made from the Settings page', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'lights';
emit('style');
assert.equal(api.sel.value, 'lights');
});
test('custom media options stay disabled until something is uploaded', () => {
const { api, store, emit } = load();
api._pcAcquire();
assert.equal(api.sel.querySelector('option[value="image"]').disabled, true);
store.customImageDataUrl = 'data:image/png;base64,AAAA';
emit('customImageDataUrl');
assert.equal(api.sel.querySelector('option[value="image"]').disabled, false);
assert.equal(api.sel.querySelector('option[value="video"]').disabled, true, 'video is independent');
});
test('re-mounts into a fresh slot when the player chrome is rebuilt', () => {
const { api, dom, sandbox, listenerCount } = load();
api._pcAcquire();
const first = api.el;
dom.root.removeChild(dom.slot);
const fresh = new dom.El('div');
dom.root.appendChild(fresh);
sandbox.window.feedBack.ui.playerControlSlot = () => fresh;
api._pcAcquire();
assert.equal(fresh.children.length, 1, 'did not remount into the new slot');
assert.notEqual(api.el, first, 'stale node was reused');
assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
});
test('a non-v3 host mounts nothing (uiVersion gate)', () => {
const ctl = load();
ctl.sandbox.window.feedBack.uiVersion = 'v2'; // pre-v3 shell
ctl.api._pcAcquire();
assert.equal(ctl.api.el, null, 'must not mount when uiVersion is not v3');
assert.equal(ctl.dom.slot.children.length, 0);
// A non-v3 shell has no slot and never will, so no retry should be scheduled
// at all — the loop is for a not-yet-built v3 slot, not for polling v2.
assert.equal(ctl.timers.length, 0, 'a non-v3 host must not schedule the retry loop');
ctl.api._pcRelease();
});
test('a host with no player-control slot mounts nothing and does not throw', () => {
const { api, dom, sandbox, timers } = load();
sandbox.window.feedBack.ui = {};
api._pcAcquire();
assert.equal(api.el, null);
assert.equal(dom.slot.children.length, 0);
let guard = 0;
while (timers.length && guard++ < 100) timers.shift()();
assert.ok(guard < 100, 'retry loop did not terminate');
});
test('intensity writes once on release, not on every drag step', () => {
const { api, writes } = load();
api._pcAcquire();
for (const v of ['0.10', '0.20', '0.30', '0.40', '0.50']) {
api.intens.value = v;
api.intens.fire('input');
}
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 0,
'dragging must not write — every write rebuilds the background scene');
api.intens.fire('change');
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 1,
'releasing must write exactly once');
});
test('the dropdown and Reactive pill drive the real setters', () => {
const { api, store, writes } = load();
api._pcAcquire();
api.sel.value = 'geometric';
api.sel.fire('change');
assert.equal(store.style, 'geometric');
const before = store.reactive;
api.react.fire('click');
assert.equal(store.reactive, !before, 'Reactive pill must toggle');
assert.ok(writes.some((w) => w[0] === 'reactive'));
});
test('exposes state and reasons to assistive tech', () => {
const ctl = load({ store: { style: 'image', reactive: true } }); // image: reactive inert
ctl.api._pcAcquire();
// The reason live-region must be a REAL mounted element with the id the
// controls reference - not a dangling pointer. Assert resolution, not a
// literal (a wrong id in code would still equal the literal).
const reason = ctl.api.reason;
assert.ok(reason, 'the reason span was not created');
assert.equal(reason.id, 'h3d-pc-reason');
assert.equal(reason.parentNode, ctl.api.el, 'the reason span must be mounted in the control');
// aria-pressed: a toggle button must expose its state. image greys
// Reactive, so not-pressed AND disabled, and it points at the reason.
assert.equal(ctl.api.react['aria-pressed'], 'false', 'greyed toggle is not pressed');
assert.equal(ctl.api.react['aria-disabled'], 'true');
// Pointer must resolve to the actual span's id (kills a wrong-id mutation),
// and the span must carry the current reason text (kills a never-set-text
// mutation).
assert.equal(ctl.api.react['aria-describedby'], reason.id, 'inert control must reference the reason span');
assert.equal(reason.textContent, 'This background does not react to audio', 'reason text must match the style');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'an ENABLED control carries no reason');
// The intensity describe path: a style where INTENSITY is inert.
ctl.store.style = 'video'; ctl.emit('style');
assert.equal(ctl.api.intens.disabled, true, 'precondition: video greys intensity');
assert.equal(ctl.api.intens['aria-describedby'], reason.id, 'inert intensity must reference the reason');
assert.equal(reason.textContent, 'The video plays as-is - nothing to adjust here');
// Both enabled: describedby drops, aria-pressed follows the value.
ctl.store.style = 'particles'; ctl.store.reactive = true; ctl.emit('style');
assert.equal(ctl.api.react['aria-describedby'], undefined, 'enabled control drops the reason');
assert.equal(ctl.api.intens['aria-describedby'], undefined);
assert.equal(ctl.api.react['aria-pressed'], 'true', 'reactive on for particles');
ctl.store.reactive = false; ctl.emit('reactive');
assert.equal(ctl.api.react['aria-pressed'], 'false', 'aria-pressed follows the value');
// Accessible names on the non-label controls.
assert.equal(ctl.api.sel['aria-label'], 'Background style');
assert.equal(ctl.api.intens['aria-label'], 'Background intensity');
ctl.api._pcRelease();
});
test('greys out exactly the controls each style ignores', () => {
const { api, store, emit } = load();
api._pcAcquire();
for (const [style, want] of Object.entries(EXPECTED_USES)) {
store.style = style;
emit('style');
assert.equal(!api.intens.disabled, want.intensity, `${style}: intensity enabled-ness`);
assert.equal(!api.react.disabled, want.reactive, `${style}: reactive enabled-ness`);
}
});
test('the Venue override greys the whole Background group', () => {
const ctl = load({ store: { style: 'particles' } }); // a style that uses both
ctl.api._pcAcquire();
assert.equal(ctl.api.intens.disabled, false, 'precondition: both enabled off-venue');
assert.equal(ctl.api.react.disabled, false);
// Venue turns on: the effective style is now 'venue', which uses neither.
// The transition arrives on the settings bus as the 'venueScene' key.
ctl.sandbox._venueSceneOverride = true;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, true, 'intensity should grey under Venue');
assert.equal(ctl.api.react.disabled, true, 'reactive should grey under Venue');
assert.equal(ctl.api.sel.disabled, true, 'the dropdown should be inert under Venue too');
assert.match(ctl.api.intens.title, /venue/i, 'reason should mention Venue');
// All three inert controls point at the reason under Venue (kills a
// 'describe reactive only' regression on the select/intensity paths).
const vReason = ctl.api.reason.id;
assert.equal(ctl.api.sel['aria-describedby'], vReason, 'select must reference the reason under Venue');
assert.equal(ctl.api.intens['aria-describedby'], vReason, 'intensity must reference the reason under Venue');
assert.equal(ctl.api.react['aria-describedby'], vReason, 'reactive must reference the reason under Venue');
assert.match(ctl.api.reason.textContent, /venue/i, 'the reason span carries the Venue text');
// The dropdown still shows the stored style (venue has no option), but
// selecting must not write while it's inert.
assert.equal(ctl.api.sel.value, 'particles');
const before = ctl.writes.length;
ctl.api.sel.value = 'lights';
ctl.api.sel.fire('change');
assert.equal(ctl.writes.length, before, 'a disabled dropdown must not write');
// Venue off: controls come back per the stored style.
ctl.sandbox._venueSceneOverride = false;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, false, 'intensity re-enables when Venue exits');
assert.equal(ctl.api.react.disabled, false);
assert.equal(ctl.api.sel.disabled, false, 'the dropdown re-enables when Venue exits');
assert.equal(ctl.api.sel.title, 'Background style', 'the base tooltip must come back, not blank');
assert.equal(ctl.api.sel['aria-describedby'], undefined, 'select drops the reason off-Venue');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'intensity drops the reason off-Venue');
ctl.api._pcRelease();
});
test('an unknown style enables both controls (fails open)', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'some_future_style';
emit('style');
assert.equal(api.intens.disabled, false);
assert.equal(api.react.disabled, false);
});
test('greyed-out controls cannot reach the setters', () => {
const { api, store, emit, writes } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
const before = writes.length;
api.intens.fire('change');
api.react.fire('click');
assert.equal(writes.length, before, 'an inert control must not write');
});
test('greyed-out controls explain themselves on hover', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'butterchurn';
emit('style');
assert.match(api.react.title, /butterchurn/i);
assert.match(api.intens.title, /butterchurn/i);
});
// A native-disabled <button>/<input> fires no pointer events, so its own
// `title` never shows on hover. The reason must therefore also sit on the
// non-disabled wrapper, and the disabled control must let the hover fall
// through (pointer-events:none) — otherwise the "says why on hover" feature is
// dead in the browser while these tests pass on the swallowed control title.
test('the greyed-out reason reaches a hoverable wrapper', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
assert.match(api.react.parentNode.title, /nothing to adjust/i,
'reactive reason must be on the wrapper, not only the disabled pill');
assert.equal(api.react.style.pointerEvents, 'none',
'disabled pill must pass hover through to its wrapper');
assert.match(api.intens.parentNode.title, /nothing to adjust/i,
'intensity reason must be on the wrapper, not only the disabled slider');
assert.equal(api.intens.style.pointerEvents, 'none',
'disabled slider must pass hover through to its wrapper');
// ...and an enabled style clears the wrapper so the control's own title wins.
store.style = 'particles';
emit('style');
assert.equal(api.react.parentNode.title, '');
assert.equal(api.intens.parentNode.title, '');
assert.equal(api.intens.style.pointerEvents, '');
});
+50 -6
View File
@@ -23,6 +23,7 @@
const INSTRUMENTS = {
guitar: { label: 'Guitar', mode: 'audio' },
bass: { label: 'Bass', mode: 'audio' },
vocals: { label: 'Vocals', mode: 'audio' },
keys: { label: 'Keys / Piano', mode: 'midi' },
piano: { label: 'Keys / Piano', mode: 'midi' },
drums: { label: 'Drums', mode: 'midi' },
@@ -133,16 +134,25 @@
const opts2 = sources.map((s) =>
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
const hasVocalCal = inst === 'vocals' && !!(window.feedBack && window.feedBack.vocalCalibration &&
window.feedBack.vocalCalibration.version === 1 &&
typeof window.feedBack.vocalCalibration.launch === 'function');
// For vocals: "Calibrate" iff vocal-cal facade present; "Continue" otherwise.
// For guitar/bass: "Calibrate" iff noteDetect present; "Continue" otherwise.
const canCalibrate = inst === 'vocals' ? hasVocalCal : hasDetector;
const notLoadedNotice = inst === 'vocals'
? (hasVocalCal ? '' : '<p class="text-xs text-fb-textDim mt-3">Vocal calibration isnt available yet — you can set it up later from the player.</p>')
: (hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isnt loaded here — you can calibrate later from the player.</p>');
const body =
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
(sources.length
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
(hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isnt loaded here — you can calibrate later from the player.</p>');
notLoadedNotice;
const foot =
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
(hasDetector ? 'Calibrate' : 'Continue') + '</button>';
(canCalibrate ? 'Calibrate' : 'Continue') + '</button>';
shell(inst, body, foot);
const sel = host.querySelector('[data-is-audio]');
@@ -162,8 +172,42 @@
// Tell the tuner tables / note_detect which instrument this is.
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
const calBtn = host.querySelector('[data-is-cal]');
let _advancing = false; // double-click guard for the auto-advance fallback
calBtn.addEventListener('click', () => {
if (inst === 'vocals') {
// Vocals calibration is handled by the vocal-highway plugin's
// facade. Guard: if the facade is absent (plugin disabled or
// not yet loaded), mark done with a notice and continue.
const vc = window.feedBack && window.feedBack.vocalCalibration;
if (vc && vc.version === 1 && typeof vc.launch === 'function') {
const ov = document.getElementById('input-setup-overlay');
const prevDisplay = ov ? ov.style.display : '';
if (ov) ov.style.display = 'none';
const restore = () => {
const o = document.getElementById('input-setup-overlay');
if (o) o.style.display = prevDisplay;
};
vc.launch({
requester: 'input_setup',
onDone: (_result) => { restore(); advance(inst, true); },
onCancel: () => { restore(); /* stay on panel; user can skip or retry */ },
});
} else {
// ponytail: vocal-highway not loaded — mark done so wizard doesn't hang
if (_advancing) return; // double-click guard: one advance per panel
_advancing = true;
calBtn.disabled = true;
const body = host.querySelector('[data-is-body]');
if (body) body.innerHTML = '<p class="text-sm text-fb-textDim">Vocal calibration will be available once the Vocal Highway plugin is enabled. Continuing…</p>';
// Store timer id so advance() (via _activeCleanup) can cancel it
// if the user hits Skip before the 1800ms fires — prevents the
// stale timer from double-advancing the wizard and dropping the
// next instrument from both completed and skipped.
const _timerId = setTimeout(() => advance(inst, true), 1800);
_activeCleanup = () => clearTimeout(_timerId);
}
} else if (hasDetector) {
// Hide our own full-screen overlay while note_detect's
// Calibration Wizard runs on top. That wizard goes
// transparent (pointer-events:none) when it minimizes to
@@ -352,7 +396,7 @@
// Settings-panel re-entry (settings.html "Set up input devices" button).
// Re-runs the wizard for the player's selected instrument paths, falling
// back to all instruments when progression isn't available.
// back to all instruments when progression isnt available.
window._inputSetupRelaunch = async function () {
let instruments = [];
try {
@@ -363,7 +407,7 @@
instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean);
}
} catch (_) { /* offline — fall back below */ }
if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums'];
if (!instruments.length) instruments = ['guitar', 'bass', 'vocals', 'keys', 'drums'];
launch(instruments);
};
})();
+18 -7
View File
@@ -7,15 +7,26 @@
#
# Pin to Tailwind 3.x so the input/config syntax matches what was
# already shipped via the Play CDN (Tailwind 4 has breaking changes).
#
# Run this from a checkout with NO untracked plugin directories present (a
# `git worktree add --detach` of this branch is the safest way). The content
# glob (tailwind.config.js) scans `./plugins/**` on disk regardless of
# .gitignore — a dev machine with private/out-of-tree plugins checked out
# locally (e.g. audio_engine, plugin_manager) will silently bake their classes
# into the committed CSS, which CI's clean checkout can never reproduce and
# will permanently fail the tailwind-fresh gate.
set -euo pipefail
cd "$(dirname "$0")/.."
# Pin to the exact version used to generate the committed CSS — committed
# artifacts must rebuild byte-stable for diff-friendly maintenance. The
# pinned version is the one that produced the current static/tailwind.min.css
# (visible in its top-of-file header comment); bump deliberately when you
# want to track upstream Tailwind 3.x updates, and regenerate the CSS in
# the same commit.
exec npx -y tailwindcss@3.4.19 \
# Byte-stable rebuilds require the exact same resolved dependency tree, not
# just the same top-level tailwindcss version: `npx -y tailwindcss@x.y.z`
# installs into a scratch npx cache and lets npm re-resolve transitive deps
# (postcss, cssnano, autoprefixer) to whatever's current on the registry at
# invocation time — those drift independently of the pinned version and
# silently produced non-reproducible output between two machines. tailwindcss
# is now a pinned devDependency (package.json/package-lock.json); `npm ci`
# before this script (both here and in CI) is what actually makes the output
# reproducible.
exec npx tailwindcss \
-c tailwind.config.js \
-i static/_tailwind.src.css \
-o static/tailwind.min.css \
+12 -3
View File
@@ -49,7 +49,7 @@ import demo_mode
import scan
import tailwind_rebuild
# 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 audio_effects, artist_aliases, loops, playlists, ws_highway, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
from routers import tunings as tunings_router
import enrichment
from routers import art as art_router
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
@app.post("/api/rescan")
def trigger_rescan():
"""Manually trigger a library rescan."""
if not scan.kick_scan():
# force=True: a manual Refresh must skip the directory-signature fast path —
# it is the escape hatch for the one change dir mtimes can't see (a pack
# rewritten in place under the same name).
if not scan.kick_scan(force=True):
return {"message": "Scan already in progress"}
return {"message": "Rescan started"}
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
# delete_missing() prunes anything genuinely gone at the end.
meta_db.conn.execute("UPDATE songs SET mtime = -1")
meta_db.conn.commit()
if not scan.kick_scan():
if not scan.kick_scan(force=True, allow_mass_prune=True):
return {"message": "Scan already in progress"}
return {"message": "Full rescan started"}
@@ -1615,6 +1618,12 @@ app.include_router(media_router.router)
app.include_router(ws_highway.router)
# ── Session-sync relay WebSocket (feedBack#1030) ─────────────────────────────
# Dumb JSON fan-out rooms for cross-device followers (splitscreen LAN mode).
# Implementation in lib/routers/ws_sync.py.
app.include_router(ws_sync.router)
# ── Audio serving ─────────────────────────────────────────────────────────────
+57 -5
View File
@@ -1150,7 +1150,7 @@ window.feedBack.on('song:ready', () => {
let _arrBusyGen = 0;
let _arrBusyTimeout = null;
async function changeArrangement(index) {
async function changeArrangement(index, drumPart) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
@@ -1276,11 +1276,38 @@ async function changeArrangement(index) {
_resetSectionPracticeLog();
invalidateParentCount();
window.highway.reconnect(currentFilename, index);
// Carry the selected drum part across the re-stream. An explicit
// `drumPart` (a drum-part switch, from changeDrumPart) wins; otherwise
// preserve the current picker selection so an ARRANGEMENT switch keeps
// the chosen part (drum parts are song-level, not per-arrangement).
const part = drumPart !== undefined
? drumPart
: (document.getElementById('drum-part-select')?.value || '');
window.highway.reconnect(currentFilename, index, part);
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
}
}
// Switch which drum part plays (feedpak 1.17.0 "drums as arrangements"). A part
// switch re-streams the same song with a different drum tab — the same
// transition as an arrangement switch — so it delegates to changeArrangement
// with the CURRENT arrangement held and the new part applied. Wired to
// #drum-part-select's onchange; the select is populated + shown by
// highway.js's song_info handler only when the song has 2+ drum parts.
async function changeDrumPart(partId) {
if (!currentFilename) return;
let index = 0;
const si = window.highway && typeof window.highway.getSongInfo === 'function'
? window.highway.getSongInfo() : null;
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
index = si.arrangement_index;
} else {
const arrSel = document.getElementById('arr-select');
if (arrSel && arrSel.value !== '') index = Number(arrSel.value) || 0;
}
return changeArrangement(index, partId);
}
// Restart the current song from the beginning (or from loop A when an AB
// loop is armed). Uses the canonical _audioSeek funnel only — never touches
// audio.currentTime directly and never reloads via playSong().
@@ -1334,12 +1361,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
// leaving the player still leaves — and abandons the queue.
window.feedBack.playQueue = (function () {
let list = [], idx = -1, source = '', arrangements = null;
// Set true by _play() right before it drives playSong, consumed once by
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
// signal is options.fromQueue, but a chain of plugin playSong wrappers
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
// (filename, arrangement) and silently drop the options object — so the flag
// never arrived and the queue cleared itself the instant its first song
// started (a gig/album/playlist never advanced). This flag rides beside the
// wrapper chain, not through it.
let _internalPlay = false;
const active = () => idx >= 0 && idx < list.length;
const hasNext = () => active() && idx < list.length - 1;
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
function _play(i) {
const fn = list[i];
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
// that survives wrapper chains dropping the options arg. Both set; either
// suffices. playSong runs its clear-guard synchronously at entry, and the
// wrapper chain reaches it synchronously, so the flag is still set then.
_internalPlay = true;
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
}
function start(files, opts) {
@@ -1371,6 +1411,15 @@ window.feedBack.playQueue = (function () {
}
return {
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
// True when the current song is a queue ADVANCE (song 2..N of a set),
// false for its first song or a standalone play. The venue uses this to
// fly in once on arrival at the set, then continue the room between
// songs instead of replaying the arrival flyover every track.
isContinuation: function () { return active() && idx > 0; },
// One-shot: true iff _play just kicked off this playSong. Consumed on
// read so a later MANUAL play still clears the queue. playSong calls this
// instead of trusting options.fromQueue to survive the wrapper chain.
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
source: function () { return source; },
remaining: function () { return active() ? list.length - idx - 1 : 0; },
// What's coming, for consumers that RENDER the queue (a results
@@ -2297,11 +2346,14 @@ configureHost({
currentFilename: () => currentFilename,
});
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
// script and called esc() back when app.js was one too and it was an implicit
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
Object.assign(window, {
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
changeArrangement, changeDrumPart, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
+2 -1
View File
@@ -128,6 +128,7 @@
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }),
visualization: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated highway renderer providers: discovery, picker selection, auto-match attribution, failure fallback, and redaction-safe diagnostics.' }),
'note-detection': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings (spec 009); consumers own judgment, hit/miss flow as observability events.' }),
'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }),
});
const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
@@ -1536,4 +1537,4 @@
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
} catch (_) {}
})();
})();
+336
View File
@@ -0,0 +1,336 @@
// Chart-transform provider registration, selection, and diagnostics.
// Transformation stays on the synchronous highway data plane and runs after
// difficulty filtering; the selected provider is shared by highway instances.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.feedBack.chartTransformDomain && window.feedBack.chartTransformDomain.version === 1) return;
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PUBLIC_FAILURE_REASON = 'Chart transform provider failed';
// providerId → { id, label, pluginId, transform }
const providers = new Map();
let activeProviderId = null;
let activeSource = 'startup';
let lastFailure = null;
// Count of highway instances the active provider is installed on
// (the primary window.highway plus any announced via highway:created —
// e.g. splitscreen panels). 0 = nothing capable exists yet.
let installedCount = 0;
// Known highway surfaces beyond window.highway, held weakly so closed
// splitscreen panels can be collected. WeakRef is guarded for minimal
// test environments; the strong-ref fallback only over-retains there.
const _HasWeakRef = typeof WeakRef === 'function';
let _surfaces = [];
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
function _snapshot(extra = {}) {
return {
available: true,
active: activeProviderId,
activeSource,
installed: installedCount > 0,
surfaces: installedCount,
providers: [...providers.values()].map(p => ({
id: p.id,
label: p.label,
pluginId: p.pluginId,
})),
lastFailure: lastFailure ? { ...lastFailure } : null,
...extra,
};
}
function _emit(name, detail) {
try { capabilities.emitEvent('chart-transform', name, detail || {}); }
catch (_) { /* eventing must not break rendering */ }
}
function _contributeDiagnostics() {
const diagnostics = window.feedBack && window.feedBack.diagnostics;
if (diagnostics && typeof diagnostics.contribute === 'function') {
try {
diagnostics.contribute('chart-transform-capability', {
schema: 'feedBack.chart_transform.diagnostics.v1',
..._snapshot(),
});
} catch (_) { /* diagnostics must not break rendering */ }
}
}
function _persistSelection(providerId) {
try {
if (providerId) window.localStorage.setItem(STORAGE_KEY, providerId);
else window.localStorage.removeItem(STORAGE_KEY);
} catch (_) { /* storage unavailable → in-memory selection only */ }
}
function _persistedSelection() {
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
catch (_) { return null; }
}
function _capable(hw) {
return !!(hw && typeof hw.setChartTransform === 'function');
}
// Every capable highway surface: window.highway plus live announced
// instances (splitscreen panels), deduped, dead refs pruned in place.
function _eachSurface(fn) {
const seen = new Set();
const primary = window.highway;
if (_capable(primary)) { seen.add(primary); fn(primary); }
const live = [];
for (const ref of _surfaces) {
const hw = _HasWeakRef ? ref.deref() : ref;
if (!hw) continue;
live.push(ref);
if (seen.has(hw) || !_capable(hw)) continue;
seen.add(hw);
fn(hw);
}
_surfaces = live;
return seen.size;
}
function _rememberSurface(hw) {
if (!_capable(hw) || hw === window.highway) return;
let known = false;
_eachSurface(() => {});
for (const ref of _surfaces) {
if ((_HasWeakRef ? ref.deref() : ref) === hw) { known = true; break; }
}
if (!known) _surfaces.push(_HasWeakRef ? new WeakRef(hw) : hw);
}
// Hand the current selection to every highway surface (or clear it).
// Selection survives with zero surfaces — it re-applies as instances
// appear (song:ready for the primary, highway:created for panels).
function _install() {
const provider = activeProviderId ? providers.get(activeProviderId) : null;
const payload = provider ? { id: provider.id, transform: provider.transform } : null;
installedCount = 0;
_eachSurface((hw) => {
try {
hw.setChartTransform(payload);
if (payload) installedCount += 1;
} catch (_) { /* one broken surface must not block the rest */ }
});
return installedCount > 0 || payload === null;
}
function _setActive(providerId, source) {
const from = activeProviderId;
activeProviderId = providerId;
activeSource = String(source || 'unknown');
_persistSelection(providerId);
_install();
if (from !== providerId) {
_emit('transform-changed', { from, to: providerId, source: activeSource });
}
_contributeDiagnostics();
}
function _payload(ctx = {}) {
return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
}
function _providersForParticipant(participantId) {
return [...providers.values()].filter(provider => provider.pluginId === participantId);
}
function _registerProviderParticipant(participantId) {
const owned = _providersForParticipant(participantId);
if (!owned.length) return;
capabilities.registerParticipant(participantId, {
'chart-transform': {
roles: ['provider'],
operations: ['chart.transform'],
events: [],
mode: 'active',
compatibility: 'none',
safety: 'safe',
runtime: true,
description: `${owned.length} registered chart transform provider${owned.length === 1 ? '' : 's'}.`,
provider_policy: {
providerIds: owned.map(provider => provider.id),
providers: owned.map(provider => ({ id: provider.id, label: provider.label })),
},
},
});
}
function _registerProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
if (typeof payload.transform !== 'function') {
return _degraded('Provider registration requires a transform(input) function', _snapshot());
}
const participantId = String(ctx.source || ctx.requester || providerId);
const existing = providers.get(providerId);
if (existing && existing.pluginId !== participantId) {
return _degraded(
`Provider ${providerId} is already registered by a different participant`,
_snapshot(),
);
}
providers.set(providerId, {
id: providerId,
label: String(payload.label || providerId),
pluginId: participantId,
transform: payload.transform,
});
_registerProviderParticipant(participantId);
_emit('provider-registered', { providerId });
// Restore a persisted selection the moment its provider appears.
if (!activeProviderId && _persistedSelection() === providerId) {
_setActive(providerId, 'restore-selection');
} else if (activeProviderId === providerId) {
// Re-registration after script rehydration: reinstall the fresh
// transform closure so the highway isn't holding a stale one.
_install();
}
_contributeDiagnostics();
return _handled(_snapshot({ registered: providerId }));
}
function _unregisterProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
const provider = providers.get(providerId);
if (!provider) return _degraded(`Unknown chart-transform provider: ${providerId || '(none)'}`, _snapshot());
const callerId = String(ctx.source || ctx.requester || providerId);
if (provider.pluginId !== callerId) {
return _degraded(
`Provider ${providerId} can only be unregistered by its original registrant`,
_snapshot(),
);
}
providers.delete(providerId);
if (activeProviderId === providerId) {
// Keep the persisted selection so the provider re-activates on
// its next registration; just detach it from the highway.
activeProviderId = null;
_install();
_emit('transform-changed', { from: providerId, to: null, source: 'provider-unregistered' });
}
const remainingProviders = _providersForParticipant(provider.pluginId);
if (remainingProviders.length) {
_registerProviderParticipant(provider.pluginId);
} else if (typeof capabilities.unregisterParticipant === 'function') {
const live = typeof capabilities.inspect === 'function' ? capabilities.inspect('chart-transform') : null;
const participant = ((live && live.participants) || []).find(p => p.pluginId === provider.pluginId);
const roles = participant && Array.isArray(participant.roles) ? participant.roles : [];
const providerOnly = roles.length === 1 && roles[0] === 'provider';
if (!participant || providerOnly) {
try { capabilities.unregisterParticipant(provider.pluginId, 'chart-transform'); }
catch (_) { /* participant cleanup is best-effort */ }
}
}
_emit('provider-unregistered', { providerId });
_contributeDiagnostics();
return _handled(_snapshot({ unregistered: providerId }));
}
function _targetProviderId(ctx = {}) {
const payload = _payload(ctx);
const target = ctx.target && typeof ctx.target === 'object' ? ctx.target : {};
return String(
target.providerId || target.provider_id || target.id
|| payload.providerId || payload.provider_id || payload.id
|| (typeof ctx.target === 'string' ? ctx.target : '') || ''
).trim();
}
function _selectProvider(ctx = {}) {
const providerId = _targetProviderId(ctx);
if (!providerId) return _degraded('Transform selection requires a provider id', _snapshot());
if (!providers.has(providerId)) {
return _degraded(`Unknown chart-transform provider: ${providerId}`, _snapshot());
}
_setActive(providerId, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ selected: providerId }));
}
function _clearProvider(ctx = {}) {
_setActive(null, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ cleared: true }));
}
function _refresh() {
if (!activeProviderId || installedCount === 0) return _handled(_snapshot({ refreshed: false }));
let refreshed = 0;
_eachSurface((hw) => {
if (typeof hw.refreshChartTransform !== 'function') return;
try { hw.refreshChartTransform(); refreshed += 1; }
catch (_) { /* one broken surface must not block the rest */ }
});
return _handled(_snapshot({ refreshed: refreshed > 0 }));
}
capabilities.registerOwner('chart-transform', {
pluginId: 'core.chart-transform',
kind: 'provider-coordinator',
safety: 'safe',
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'clear-provider', 'refresh'],
operations: ['chart.transform'],
events: ['provider-registered', 'provider-unregistered', 'transform-changed', 'transform-failed'],
description: 'Owns chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with selection, refresh, and failure attribution.',
handlers: {
inspect: () => _handled(_snapshot()),
'list-providers': () => _handled(_snapshot()),
'register-provider': (ctx) => _registerProvider(ctx),
'unregister-provider': (ctx) => _unregisterProvider(ctx),
'select-provider': (ctx) => _selectProvider(ctx),
'clear-provider': (ctx) => _clearProvider(ctx),
refresh: () => _refresh(),
},
});
// Bus mirroring (guarded: the bus may not exist in minimal/test envs).
const sm = window.feedBack;
if (typeof sm.on === 'function') {
try {
sm.on('highway:chart-transform-failed', (e) => {
const detail = (e && e.detail) || e || {};
lastFailure = {
providerId: String(detail.id || activeProviderId || 'unknown'),
reason: PUBLIC_FAILURE_REASON,
};
_emit('transform-failed', { ...lastFailure });
_contributeDiagnostics();
});
// The primary highway is created after this module evaluates —
// install a pending selection once a song is loading/ready.
sm.on('song:ready', () => {
if (activeProviderId && installedCount === 0 && _install()) {
// setChartTransform restages immediately, so the chart
// that just became ready picks the transform up now.
_contributeDiagnostics();
}
});
// Additional instances restage the active provider against their
// own chart state.
sm.on('highway:created', (e) => {
const detail = (e && e.detail) || e || {};
if (!_capable(detail.highway)) return;
_rememberSurface(detail.highway);
if (activeProviderId) _install();
_contributeDiagnostics();
});
} catch (_) { /* bus mirroring is best-effort */ }
}
window.feedBack.chartTransformDomain = {
version: 1,
snapshot: _snapshot,
};
_contributeDiagnostics();
})();
+268 -25
View File
@@ -267,6 +267,19 @@ function createHighway() {
hwState._filteredChords = null;
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
// Transform stage; null fields fall through to filtered/original data.
hwState._xfProvider = null; // { id, transform } or null
hwState._xfNotes = null; // effective (post-filter) views
hwState._xfChords = null;
hwState._xfAnchors = null;
hwState._xfNotesAll = null; // full-difficulty views (getNotes/getChords)
hwState._xfChordsAll = null;
hwState._xfChordTemplates = null;
hwState._xfStringCount = null; // number or null
hwState._xfTuning = null; // array or null
hwState._xfCapo = null; // number or null
hwState._xfHandShapes = null; // array or null
hwState._xfCentOffset = null; // number or null
// Tracks whether ANY phrase level carries handshape data. Lets us
// distinguish "this difficulty has none" (respect strictly — even
// when empty) from "the chart's phrase data never authored any
@@ -397,7 +410,8 @@ function createHighway() {
function getAnchorAt(t) {
// Same master-difficulty fallback as the render loops — the
// anchor ladder pairs with the note ladder.
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
let a = src[0] || { fret: 1, width: 4 };
for (const anc of src) {
if (anc.time > t) break;
@@ -408,7 +422,8 @@ function createHighway() {
function getMaxFretInWindow(t) {
// Find the highest fret needed across all anchors visible on screen
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
let maxFret = 0;
for (const anc of src) {
if (anc.time > t + VISIBLE_SECONDS + 2) break; // Skip anchors well in the future (with a little buffer to avoid moving early the cutoff)
@@ -541,17 +556,20 @@ function createHighway() {
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
b.notes = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.chords = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
b.anchors = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
b.notes = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.chords = hwState._xfChords !== null ? hwState._xfChords
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
b.anchors = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
b.beats = hwState.beats;
b.sections = hwState.sections;
b.chordTemplates = hwState.chordTemplates;
b.stringCount = hwState.stringCount;
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
b.tuning = hwState.songInfo?.tuning;
b.capo = hwState.songInfo?.capo;
b.chordTemplates = hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
b.stringCount = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
// Effective tuning metadata; live references like the chart arrays.
b.tuning = hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning;
b.capo = hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo;
b.centOffset = hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset;
b.lyrics = hwState.lyrics;
b.lyricsSource = hwState.lyricsSource;
b.toneChanges = hwState.toneChanges;
@@ -572,9 +590,10 @@ function createHighway() {
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes;
b.handShapes = hwState._xfHandShapes !== null ? hwState._xfHandShapes
: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes;
// Display flags
b.inverted = hwState._inverted;
@@ -986,6 +1005,22 @@ function createHighway() {
// inline arrow function.
function _handleAsyncInitFailure(e) {
if (hwState._renderer !== _installedRenderer) return;
// ...and ignore a rejection from a SUPERSEDED init cycle.
//
// A renderer mints a fresh readyPromise on every init(), and
// rejects the previous one ("superseded") when a newer init
// starts. The renderer object is unchanged, so the identity
// check above does not catch it — and we would tear down a
// perfectly healthy renderer that is merely re-initialising.
//
// This is exactly what starting a gig did: setViz('venue')
// installed the 3D renderer, then the queue's playSong()
// re-initialised it a tick later; init #1's promise rejected,
// and the gig dropped to the fallback 2D highway with the
// venue gone. A superseded init is not a failed init — the
// NEW cycle owns the outcome, and its own promise is what we
// must judge.
if (_installedRenderer.readyPromise !== rp) return;
console.error('renderer async init failure:', e);
_destroyCurrentIfInited();
hwState._renderer = _defaultRenderer;
@@ -1159,6 +1194,17 @@ function createHighway() {
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
}
// Optional renderer capability: "my picture keeps moving even when the chart
// clock is stopped". Anything a renderer animates on its own clock (the 3D
// highway's venue video + crowd) has to opt out of the paused-frame throttle
// or it renders at 10 fps while the song is paused. Absent / throwing =
// false, so every existing renderer keeps the throttle unchanged.
function _rendererNeedsContinuousFrames() {
const r = hwState._renderer;
if (!r || typeof r.needsContinuousFrames !== 'function') return false;
try { return r.needsContinuousFrames() === true; } catch (_) { return false; }
}
function draw() {
hwState.animFrame = requestAnimationFrame(draw);
if (!hwState.canvas || !hwState._renderer) return;
@@ -1223,7 +1269,15 @@ function createHighway() {
const _nowP = performance.now();
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
_paused = true;
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
// ...unless the renderer says its picture is NOT static while
// paused. The throttle assumes a paused chart is a still frame,
// but a renderer can own content on a clock of its own — the 3D
// highway draws the venue's video backdrop and its reactive crowd
// into this same canvas, so throttling the highway throttled the
// whole room to 10 fps whenever the song was paused. Optional
// method: renderers that don't implement it keep the throttle.
if (!_rendererNeedsContinuousFrames()
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
hwState._lastPausedDrawAt = _nowP;
}
}
@@ -1337,9 +1391,10 @@ function createHighway() {
// slots, so 4 strings spread across the full band rather than
// using the upper 4/6ths of the 6-string layout. The Math.max
// guards against a hypothetical 1-string instrument (denom=0).
const span = Math.max(1, hwState.stringCount - 1);
for (let i = 0; i < hwState.stringCount; i++) {
const yi = hwState._inverted ? (hwState.stringCount - 1 - i) : i;
const sc = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
const span = Math.max(1, sc - 1);
for (let i = 0; i < sc; i++) {
const yi = hwState._inverted ? (sc - 1 - i) : i;
const y = strTop + (yi / span) * (strBot - strTop);
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
hwState.ctx.lineWidth = 3;
@@ -1442,6 +1497,7 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
_restageChartTransform();
return;
}
const outNotes = [];
@@ -1489,6 +1545,116 @@ function createHighway() {
}
hwState._filteredHandShapes = outHandShapes;
hwState._phrasesHaveHandShapes = anyHandShapeInPhrases;
_restageChartTransform();
}
function _clearChartTransformStage() {
hwState._xfNotes = null;
hwState._xfChords = null;
hwState._xfAnchors = null;
hwState._xfNotesAll = null;
hwState._xfChordsAll = null;
hwState._xfChordTemplates = null;
hwState._xfStringCount = null;
hwState._xfTuning = null;
hwState._xfCapo = null;
hwState._xfHandShapes = null;
hwState._xfCentOffset = null;
}
function _cloneChartTransformValue(value, seen = new WeakMap()) {
if (!value || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
const copy = Array.isArray(value) ? new Array(value.length) : {};
seen.set(value, copy);
for (const key of Object.keys(value)) {
Object.defineProperty(copy, key, {
value: _cloneChartTransformValue(value[key], seen),
enumerable: true,
configurable: true,
writable: true,
});
}
return copy;
}
function _sortedChartTransformArray(items, key) {
return items.slice().sort((a, b) => a[key] - b[key]);
}
function _reportChartTransformFailure(provider, error) {
_clearChartTransformStage();
console.error('chart transform:', error);
if (window.feedBack && typeof window.feedBack.emit === 'function') {
try {
window.feedBack.emit('highway:chart-transform-failed', {
id: provider.id,
});
} catch (_) { /* eventing must not break rendering */ }
}
}
// Stage one synchronous transform over the difficulty-filtered chart.
function _restageChartTransform() {
_clearChartTransformStage();
const p = hwState._xfProvider;
if (!p) return;
// Pre-ready there is nothing meaningful to transform (chart arrays
// are still streaming, songInfo may be empty) — keep the provider
// attached and let the `ready` path (which sets hwState.ready BEFORE
// _rebuildMasteryFilter) run the first real staging.
if (!hwState.ready) return;
const filterActive = hwState._filteredNotes !== null;
try {
let out = p.transform(_cloneChartTransformValue({
notes: filterActive ? hwState._filteredNotes : hwState.notes,
chords: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords,
anchors: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors,
allNotes: hwState.notes,
allChords: hwState.chords,
chordTemplates: hwState.chordTemplates,
// Same effective selection the bundle uses (see b.handShapes).
handShapes: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes,
stringCount: hwState.stringCount,
songInfo: hwState.songInfo,
}));
if (out && typeof out.then === 'function') {
try {
const catchAsyncFailure = out.catch;
if (typeof catchAsyncFailure === 'function') {
catchAsyncFailure.call(out, error => console.error('chart transform async:', error));
}
} catch (_) { /* the synchronous failure below remains authoritative */ }
throw new TypeError('Chart transform providers must return synchronously');
}
if (!out || typeof out !== 'object') return;
out = _cloneChartTransformValue(out);
if (Array.isArray(out.notes)) hwState._xfNotes = _sortedChartTransformArray(out.notes, 't');
if (Array.isArray(out.chords)) hwState._xfChords = _sortedChartTransformArray(out.chords, 't');
if (Array.isArray(out.anchors)) hwState._xfAnchors = _sortedChartTransformArray(out.anchors, 'time');
// Full-difficulty views: explicit allNotes/allChords, or reuse the
// effective output when no filter is active (effective === raw then).
if (Array.isArray(out.allNotes)) hwState._xfNotesAll = _sortedChartTransformArray(out.allNotes, 't');
else if (!filterActive && Array.isArray(out.notes)) hwState._xfNotesAll = hwState._xfNotes;
if (Array.isArray(out.allChords)) hwState._xfChordsAll = _sortedChartTransformArray(out.allChords, 't');
else if (hwState._filteredChords === null && Array.isArray(out.chords)) hwState._xfChordsAll = hwState._xfChords;
if (Array.isArray(out.chordTemplates)) hwState._xfChordTemplates = out.chordTemplates;
if (Number.isFinite(out.stringCount) && out.stringCount >= 1) {
// Same [1, 8] clamp as the song_info stringCount handler.
hwState._xfStringCount = Math.max(1, Math.min(8, Math.trunc(out.stringCount)));
}
if (Array.isArray(out.tuning) && out.tuning.length) hwState._xfTuning = out.tuning;
if (Number.isFinite(out.capo) && out.capo >= 0) hwState._xfCapo = Math.trunc(out.capo);
if (Array.isArray(out.handShapes)) {
hwState._xfHandShapes = _sortedChartTransformArray(out.handShapes, 'start_time');
}
if (Number.isFinite(out.centOffset)) hwState._xfCentOffset = out.centOffset;
} catch (e) {
_reportChartTransformFailure(p, e);
return;
}
}
// ── Public API ───────────────────────────────────────────────────────
@@ -1533,6 +1699,8 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState();
},
@@ -2130,6 +2298,31 @@ function createHighway() {
sel.appendChild(opt);
}
}
// Drum-part picker (feedpak 1.17.0 "drums as
// arrangements"): a song can carry several drum
// charts. Populate the picker beside the
// arrangement switcher; show it only when there
// are 2+ parts to choose between. `drum_parts`
// is always present (empty for non-drum songs),
// so a single-drum / no-drum song hides it. The
// currently-streaming part is marked selected by
// the `drum_tab` handler below (authoritative
// `part_id`), so we don't guess here.
{
const dpSel = document.getElementById('drum-part-select');
if (dpSel) {
const parts = Array.isArray(msg.drum_parts) ? msg.drum_parts : [];
dpSel.textContent = '';
for (const p of parts) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
dpSel.appendChild(opt);
}
const dpRow = document.getElementById('v3-drum-part-row');
if (dpRow) dpRow.classList.toggle('hidden', parts.length <= 1);
}
}
}
// Plugin context API — broadcast current song state
if (window.feedBack) {
@@ -2212,7 +2405,22 @@ function createHighway() {
name: (typeof msg.name === 'string' && msg.name) ? msg.name : 'Drums',
kit: Array.isArray(msg.kit) ? msg.kit : [],
hits: [],
// Which drum part this stream carries (feedpak
// 1.17.0). Present only for multi-part packs;
// null otherwise. Plugins can read it via
// bundle.drumTab.part_id.
part_id: (typeof msg.part_id === 'string' && msg.part_id) ? msg.part_id : null,
};
// Reflect the authoritative streaming part in the
// picker (the server resolves an unknown/absent
// selection to the primary, so this keeps the
// dropdown honest even after a fallback).
if (hwState.drumTab.part_id) {
const dpSel = document.getElementById('drum-part-select');
if (dpSel && dpSel.value !== hwState.drumTab.part_id) {
dpSel.value = hwState.drumTab.part_id;
}
}
break;
case 'drum_hits':
if (hwState.drumTab && Array.isArray(msg.data)) {
@@ -2419,8 +2627,11 @@ function createHighway() {
hwState._domVisSampledFrame = NaN;
return _isHighwayVisible();
},
getNotes() { return hwState.notes; },
getChords() { return hwState.chords; },
// When a chart transform is active these return its full-difficulty
// views (falling through to the original arrays if the provider
// supplied only the filtered view).
getNotes() { return hwState._xfNotesAll !== null ? hwState._xfNotesAll : hwState.notes; },
getChords() { return hwState._xfChordsAll !== null ? hwState._xfChordsAll : hwState.chords; },
// Difficulty-filtered variants of getNotes()/getChords(). Returns the
// master-difficulty-filtered arrays when the current song has phrase-level
// data (i.e. the mastery slider is active). For songs with a single
@@ -2428,8 +2639,14 @@ function createHighway() {
// these fall through to the raw arrays, the same as getNotes()/getChords().
// Plugins that score or analyse only the notes the player is currently
// expected to play should prefer these over getNotes()/getChords(). Read-only.
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; },
getFilteredChords() { return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; },
getFilteredNotes() {
if (hwState._xfNotes !== null) return hwState._xfNotes;
return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
},
getFilteredChords() {
if (hwState._xfChords !== null) return hwState._xfChords;
return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
},
// Live reference to the chord-template lookup table —
// `getChords()[i].id` is an index into this array. Each
// template carries `{ name, fingers, frets }`:
@@ -2444,7 +2661,7 @@ function createHighway() {
// its entries. Not difficulty-filter-aware (templates are
// static metadata; every chord_id referenced by `getChords()`
// is guaranteed valid).
getChordTemplates() { return hwState.chordTemplates; },
getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
getToneChanges() { return hwState.toneChanges; },
getToneBase() { return hwState.toneBase; },
getSections() { return hwState.sections; },
@@ -2472,7 +2689,10 @@ function createHighway() {
// string-indexed UI / geometry against THIS rather than
// assuming 6. Defaults to 6 between songs (until the next
// song_info message arrives).
getStringCount() { return hwState.stringCount; },
getStringCount() { return hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount; },
getTuning() { return hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning; },
getCapo() { return hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo; },
getCentOffset() { return hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset; },
addDrawHook(fn) {
hwState._drawHooks.push(fn);
},
@@ -2496,6 +2716,17 @@ function createHighway() {
*/
setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; },
getNoteStateProvider() { return hwState._noteStateProvider; },
// Install one synchronous provider for this highway. The capability
// domain owns registration and selection; null clears the provider.
setChartTransform(p) {
hwState._xfProvider = (p && typeof p.transform === 'function')
? { id: String(p.id || 'anonymous'), transform: p.transform }
: null;
_restageChartTransform();
},
getChartTransform() { return hwState._xfProvider; },
// Re-run the installed provider (e.g. its target settings changed).
refreshChartTransform() { _restageChartTransform(); },
/** Current per-string base colors (copy). Index 0..7. */
getStringColors() { return hwState.STRING_COLORS.slice(); },
/**
@@ -2582,7 +2813,7 @@ function createHighway() {
localStorage.setItem('showFingerHints', String(hwState._showFingerHints));
},
reconnect(filename, arrangement) {
reconnect(filename, arrangement, drumPart) {
// Close old WS but keep audio + animation running
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
hwState.ready = false;
@@ -2603,9 +2834,16 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState();
const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
// Multiple drum parts (feedpak 1.17.0 "drums as arrangements"):
// carry the selected part id so the WS streams ITS drum tab. Empty
// / undefined → the primary part (server default), i.e. today's
// one-drum behavior for any pack the picker never touched.
if (drumPart) wsParams.set('drum_part', drumPart);
let namingMode = 'smart';
if (typeof window._getArrangementNamingMode === 'function') {
const v = window._getArrangementNamingMode();
@@ -2700,6 +2938,11 @@ function createHighway() {
*/
isDefaultRenderer() { return hwState._renderer === _defaultRenderer || hwState._renderer == null; },
};
// Let cross-instance coordinators discover this highway.
if (window.feedBack && typeof window.feedBack.emit === 'function') {
try { window.feedBack.emit('highway:created', { highway: api }); }
catch (e) { console.error('highway:created emit:', e); }
}
return api;
}
const highway = createHighway();
+78 -5
View File
@@ -1,4 +1,4 @@
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
// Count-in — the one-bar 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
@@ -40,6 +40,75 @@ export function playClick(high = false) {
osc.stop(_audioCtx.currentTime + 0.08);
}
// ── How many clicks lead into `startT` ──────────────────────────────────
// One bar, derived from the song_timeline beats: `window.highway.getBeats()`
// is the only meter data the frontend holds (the `time_signatures` map is
// streamed to plugins, not stored here). Beats carry `measure >= 0` on
// downbeats, so the gap between consecutive downbeats IS the bar length —
// which is why a 3/4 song no longer gets four clicks.
//
// A first bar shorter than that is a pickup (anacrusis), and the count is
// shortened by its length so the music enters on its real beat: a 1-beat
// pickup in 4/4 counts "1 2 3" and the pickup lands on 4. Counting a full
// four there puts the pickup where the downbeat belongs, and the player comes
// in a beat late for the whole song.
export function countInBeats(startT) {
const DEFAULT = 4; // pre-chart, synthetic highway (minigames), or no beats
let beats = null;
try {
if (window.highway && typeof window.highway.getBeats === 'function') {
beats = window.highway.getBeats();
}
} catch (_) { /* fall through to the default */ }
if (!Array.isArray(beats) || beats.length < 2) return DEFAULT;
const downbeats = [];
for (let i = 0; i < beats.length; i++) {
if (beats[i] && beats[i].measure >= 0) downbeats.push(i);
}
if (downbeats.length < 2) return DEFAULT;
// Bar length = the most common gap between downbeats. The mode rather than
// the first gap: it ignores a short pickup bar and a short final bar, and
// survives an isolated meter change mid-song. The beats trailing the last
// downbeat count as a candidate too — otherwise a song of pickup + one bar
// offers only the pickup's own gap and the count collapses to it.
const gapCounts = new Map();
const addGap = (gap) => gapCounts.set(gap, (gapCounts.get(gap) || 0) + 1);
for (let k = 1; k < downbeats.length; k++) {
addGap(downbeats[k] - downbeats[k - 1]);
}
addGap(beats.length - downbeats[downbeats.length - 1]);
let barLen = DEFAULT;
let bestCount = 0;
for (const [gap, n] of gapCounts) {
// Tie → the longer bar: a pickup's short gap must not outvote the
// real meter when the song is too short to repeat it.
if (n > bestCount || (n === bestCount && gap > barLen)) {
barLen = gap;
bestCount = n;
}
}
// The beat playback resumes on. The 50 ms tolerance matches the seek
// precision the loop-wrap path already assumes.
const startIdx = beats.findIndex(b => b && b.time >= startT - 0.05);
if (startIdx === -1) return barLen; // past the last beat
if (!(beats[startIdx].measure >= 0)) return barLen; // resuming mid-bar
const nextDownbeat = downbeats.find(d => d > startIdx);
if (nextDownbeat === undefined) return barLen; // the last downbeat
const thisBar = nextDownbeat - startIdx;
if (thisBar <= 0) return barLen;
// Only the song's FIRST bar can be a pickup. A short bar anywhere else is
// a meter change (or a truncated final bar), and counting it as a pickup
// would leave almost no count-in at all — so elsewhere we simply count
// that bar's own length, which is also what a mid-song meter change wants.
if (startIdx === downbeats[0] && thisBar < barLen) return barLen - thisBar;
return thisBar;
}
let _countingIn = false;
let _countOverlay = null;
// Generation token so teardown can cancel an in-progress count-in. Each
@@ -273,12 +342,15 @@ export async function startCountIn(opts = {}) {
function beginCount() {
const bpm = window.highway.getBPM(loopA);
const beatInterval = 60 / bpm;
// One bar of the meter at loop A (a short bar there is counted short,
// same as the song-start pickup).
const clicks = countInBeats(loopA);
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
if (count > clicks) {
hideCountOverlay();
_countingIn = false;
if (window._juceMode) {
@@ -320,7 +392,7 @@ export async function startCountIn(opts = {}) {
}
}
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
// Start-of-song count-in: a one-bar 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-
@@ -340,14 +412,15 @@ export async function startSongCountIn() {
if (gen !== _countInGen) return; // teardown during pause
const startT = S.lastAudioTime || 0;
let bpm = window.highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
// Pre-chart / malformed-tempo fallback: 120 BPM (500 ms per beat).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
const beatInterval = 60 / bpm;
const clicks = countInBeats(startT);
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
if (count > clicks) {
hideCountOverlay();
_countingIn = false;
// Hand off to the normal play path — togglePlay() flips isPlaying,
+21 -10
View File
@@ -400,8 +400,9 @@ export function drawSustains(hwState, W, H) {
// Same master-difficulty fallback as drawNotes/drawChords —
// without this, sustain bars for filtered-out notes would
// still render, leaving orphan rectangles where no note head
// is drawn.
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// is drawn. An active chart transform substitutes its staged view.
const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
for (const n of src) {
if (n.sus <= 0.01) continue;
const end = n.t + n.sus;
@@ -501,7 +502,9 @@ export function drawNotes(hwState, W, H) {
// phrase-level ladder data, render from the mastery-filtered
// array. _filteredNotes stays null for slider-disabled sources
// so rendering falls through to the flat notes array unchanged.
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// An active chart transform (_xfNotes) substitutes its staged view.
const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// Binary search for visible range
const tMin = hwState.currentTime - 0.25;
const tMax = hwState.currentTime + VISIBLE_SECONDS;
@@ -649,7 +652,8 @@ export function drawUnisonBends(hwState, W, H, drawnNotes) {
export function drawChords(hwState, W, H) {
// See drawNotes — _filteredChords is null for slider-disabled
// sources so we fall through to the flat chords array.
const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
const src = hwState._xfChords !== null ? hwState._xfChords
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
_ensureChordRenderCache(hwState, src);
const tMin = hwState.currentTime - 0.25;
@@ -674,7 +678,7 @@ export function drawChords(hwState, W, H) {
const actualSpread = Math.max(spread, minSpread);
const actualTotalH = actualSpread * Math.max(0, sorted.length - 1);
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const hasNonZero = nonZeroNotes.length >= 1;
const frameLeftFret = baseFret;
@@ -1124,15 +1128,22 @@ export function getChordTemplateInfo(chordId, chordTemplates) {
return { tmpl, tmplFrets, getTemplateFret, isOpen };
}
// Effective chord templates: an active chart transform substitutes its
// re-indexed table (identity change also invalidates the render cache).
export function _effChordTemplates(hwState) {
return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
}
// Build _chordRenderInfo for every chord in `src` if the cache is stale.
// Two passes over the array: chain bounds, then base-fret resolution
// (which can read previous chord's cached baseFret).
export function _ensureChordRenderCache(hwState, src) {
const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates;
const effTemplates = _effChordTemplates(hwState);
const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates;
if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
hwState._chordRenderCacheSrc = src;
hwState._chordRenderCacheInverted = hwState._inverted;
hwState._chordRenderCacheTemplates = hwState.chordTemplates;
hwState._chordRenderCacheTemplates = effTemplates;
// Templates feed isOpen() — when they land after `chords`,
// _updateFretLinePreview's stashed open/non-open classification
// for the currently-active chord is also stale. It only refreshes
@@ -1188,7 +1199,7 @@ export function _ensureChordRenderCache(hwState, src) {
for (let i = 0; i < src.length; i++) {
const ch = src[i];
const info = hwState._chordRenderInfo.get(ch);
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, effTemplates);
const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s);
const nonZero = sortedNotes.filter(cn => !isOpen(cn));
const nonZeroFrets = nonZero.map(cn => cn.f);
@@ -1248,7 +1259,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
ch.t > bestChordTime) {
bestChordTime = ch.t;
activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
}
@@ -1260,7 +1271,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
const p = project(ch.t - hwState.currentTime);
if (!p) continue;
activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
break;
+71 -4
View File
@@ -410,6 +410,51 @@ function _applyLibraryProviderToParams(params) {
return params;
}
// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
// A song's bass chart is often tuned differently from its guitar chart, so the
// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
// badge must all speak for the instrument the player actually plays. Read the
// host's working-tuning capability (the live selection, seeded from
// /api/settings at boot) rather than adding another settings fetch; hosts
// without the capability keep the guitar behaviour.
const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
let _libSettingsProfile = '';
export function _setLibraryProfile(profileId) {
_libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
}
export function _libraryInstrument() {
// The PROFILE is the only three-valued source (lead / rhythm / bass); the
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
// so it is only the fallback.
if (_libSettingsProfile) return _libSettingsProfile;
try {
const wt = window.feedBack?.workingTuning;
if (wt && typeof wt.get === 'function') {
const cur = wt.get();
if (cur?.instrument === 'bass') return 'bass';
}
} catch { /* capability absent/erroring — lead guitar is the safe default */ }
return 'guitar-lead';
}
export function _libraryInstrumentLabel() {
const p = _libraryInstrument();
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
}
// The tuning a row should SHOW: the bass chart's for a bass player, falling
// back to the song (guitar-derived) tuning when the song has no bass
// arrangement — the common case, not an edge path.
function _rowTuningRaw(song) {
const p = _libraryInstrument();
const field = p === 'bass' ? 'bass_tuning_name'
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
if (field && song[field]) return song[field];
return song.tuning || song.tuning_name || '';
}
export function _resetLibraryProviderViewState() {
L.libEpoch++;
L.currentPage = 0;
@@ -768,6 +813,8 @@ export function _applyLibFiltersToParams(params) {
if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(','));
if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics));
if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(','));
// Which instrument's tuning the `tunings` filter + the tuning sort read.
if (_libraryInstrument() !== 'guitar-lead') params.set('instrument', _libraryInstrument());
return params;
}
@@ -851,6 +898,7 @@ async function _renderTuningList() {
c.innerHTML = '<div class="text-xs text-gray-500 px-2">Loading...</div>';
try {
const params = _applyLibraryProviderToParams(new URLSearchParams());
params.set('instrument', _libraryInstrument());
const resp = await fetch(`/api/library/tuning-names?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
@@ -869,6 +917,11 @@ async function _renderTuningList() {
fetchError = e.message || 'request failed';
}
}
// NAME the perspective: silent instrument-following is the original bug in
// a new place — the user must be able to see which instrument these
// tunings describe.
const labelEl = document.getElementById('filter-tunings-label');
if (labelEl) labelEl.textContent = `Tuning (${_libraryInstrumentLabel()})`;
c.innerHTML = '';
if (fetchError) {
c.innerHTML = `<div class="text-xs text-red-400 px-2">Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.</div>`;
@@ -894,10 +947,17 @@ async function _renderTuningList() {
const checked = _libFilters.tunings.includes(val);
const row = document.createElement('label');
row.className = 'tuning-row';
// Be honest about the fallback: songs with no bass arrangement borrow
// the guitar chart's tuning, and that must be visible rather than
// presented as a measured bass tuning.
const inferred = t.inferred_count || 0;
if (inferred) {
row.title = `${inferred} of ${t.count} inferred from the guitar chart (no bass arrangement)`;
}
row.innerHTML =
`<input type="checkbox" ${checked ? 'checked' : ''} class="rounded border-gray-600 bg-dark-700 text-accent">` +
`<span class="flex-1">${esc(label)}</span>` +
`<span class="tuning-count">${t.count}</span>`;
`<span class="tuning-count">${t.count}${inferred ? ` (${inferred}~)` : ''}</span>`;
const cb = row.querySelector('input');
cb.onchange = () => {
const i = _libFilters.tunings.indexOf(val);
@@ -1244,6 +1304,10 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
const duration = song.duration ? formatTime(song.duration) : '';
const tuningRaw = song.tuning || song.tuning_name || '';
const tuning = displayTuningName(tuningRaw);
// The BADGE follows the player's instrument; `tuning` above stays the
// song's guitar-derived tuning because the retune action below rewrites
// the chart to E Standard and must not key on the bass part.
const tuningBadge = displayTuningName(_rowTuningRaw(song));
const artUrl = _librarySongArtUrl(song, providerId);
const isLocalProvider = _isLocalLibraryProvider(providerId);
const isSloppak = song.format === 'sloppak';
@@ -1299,7 +1363,7 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
</div>
<div class="flex items-center flex-wrap gap-1.5 mt-3 text-xs">
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
${tuningBadge ? `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>` : ''}
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
@@ -1470,6 +1534,9 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
const duration = song.duration ? formatTime(song.duration) : '';
const tuningRaw = song.tuning || song.tuning_name || '';
const tuning = displayTuningName(tuningRaw);
// Badge follows the player's instrument; the retune action below
// keeps operating on the song's guitar-derived tuning.
const tuningBadge = displayTuningName(_rowTuningRaw(song));
const isLocalProvider = _isLocalLibraryProvider(providerId);
const isSloppak = song.format === 'sloppak';
const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd &&
@@ -1496,8 +1563,8 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
{ const _nm = _getArrangementNamingMode();
for (const arrangement of (song.arrangements || []))
html += _arrangementBadgeHtml(arrangement, _nm); }
if (tuning)
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
if (tuningBadge)
html += `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>`;
if (song.has_lyrics)
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
if (song.user_difficulty != null)
+12 -3
View File
@@ -638,9 +638,18 @@ export let artAbortController = null;
export async function playSong(filename, arrangement, options) {
console.log('playSong called:', filename);
// A manual (non-queue) play abandons any active play-queue, so a stale queue
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.clear();
// can't hijack the next song's end. The queue signals a play it is DRIVING
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
// band). The out-of-band one exists because plugin playSong wrappers forward
// only (filename, arrangement) and drop the options object — with just the
// in-band flag, the queue cleared itself the instant its first song played
// and a gig never advanced. Consume the flag whether or not we go on to clear,
// so it can't leak into a later manual play.
const _pq = window.feedBack && window.feedBack.playQueue;
const _queueDriven = (options && options.fromQueue)
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
if (!_queueDriven && _pq) {
_pq.clear();
}
if (!options || options.bridge !== false) {
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
+261 -79
View File
@@ -18,7 +18,7 @@
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
import { hwcInitSettingsUI } from './highway-colors.js';
import { _getArrangementNamingMode } from './library.js';
import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
import {
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
} from './player-controls.js';
@@ -111,6 +111,10 @@ export async function loadSettings() {
if (dlcEl) dlcEl.value = data.dlc_dir || '';
_defaultArrangement = data.default_arrangement || '';
_syncDefaultArrangementSelect(_defaultArrangement);
// Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
// tuning facet, filter, sort and badges all answer for the profile the
// player actually plays.
_setLibraryProfile(data.active_instrument_profile);
const pathwayEl = document.getElementById('setting-instrument-pathway');
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
const demucsEl = document.getElementById('demucs-server-url');
@@ -209,9 +213,66 @@ export function setupWindowOptions() {
}
}
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly'];
export let _appUpdatesWired = false;
// Poll handle for the active-download watcher (module-scoped so re-running
// setupAppUpdates on a panel re-render never stacks a second poll).
let _appUpdatePollTimer = null;
// Last channel main actually acknowledged (initial sync or a successful
// user switch). Used to revert the dropdown/localStorage if a switch fails,
// so the UI/persisted state can never end up ahead of the real updater state.
let _appUpdateAckedChannel = null;
// Last [update-diag] renderFrom line logged, so the ~1.5s download poll (and
// repeated no-op re-renders) don't flood the diagnostics ring buffer with
// byte-identical lines and evict genuinely useful trace. Every real state or
// percent change still differs and logs; the structured contribute() snapshot
// (with its own ts) is unconditional, so liveness is never lost.
let _appUpdateLastRenderLog = null;
// Pure status → view model for the App-updates panel. DOM-free and exported so
// the button/channel/text state machine can be unit-tested without a browser;
// renderFrom() applies the returned shape to the DOM. `canApply` is whether the
// bridge exposes apply() (older bridges fall back to text-only), `fmtTimestamp`
// formats the "last checked" time, `channelValue` is the dropdown's fallback
// when the status omits a channel.
export function _appUpdateStatusView(s, { channelValue, canApply = true, fmtTimestamp = (t) => String(t) } = {}) {
if (!s) return { kind: 'unavailable' };
if (s.status === 'unsupported' || s.platform === 'linux') return { kind: 'unsupported' };
const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelValue}`;
let action;
let btnLabel = 'Check for updates';
let btnMode = 'check';
let btnDisabled = false;
// Lock the channel selector only while a check/download is in flight —
// switching mid-operation abandons it. Enabled for every other status.
const channelDisabled = s.status === 'checking' || s.status === 'downloading';
switch (s.status) {
case 'checking':
action = 'checking for updates…';
btnDisabled = true;
break;
case 'downloading': {
const pct = typeof s.percent === 'number' ? s.percent : null;
action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`;
btnDisabled = true;
break;
}
case 'downloaded':
action = 'update ready';
if (canApply) { btnLabel = 'Restart now'; btnMode = 'restart'; }
else { action = 'update ready — restart to apply'; }
break;
case 'error':
action = s.message ? `update error: ${s.message}` : 'update check failed';
break;
case 'idle':
default:
action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`;
break;
}
return { kind: 'status', line: `${base} · ${action}`, btnLabel, btnMode, btnDisabled, channelDisabled };
}
export function setupAppUpdates() {
const block = document.getElementById('app-updates-block');
@@ -244,13 +305,29 @@ export function setupAppUpdates() {
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
channelSelect.value = stored;
_appUpdateAckedChannel = stored;
const isLinux = window.feedBackDesktop?.platform === 'linux';
// Diagnostic: every entry into this function, with whether the one-time
// sync gate has already fired. _appUpdatesWired is a MODULE-level `let`,
// so it only resets to false on a genuine fresh evaluation of this
// script (a real page reload/navigation) — not on loadSettings() simply
// being called again within the same page. A second "wired=false" in one
// exported log is direct proof of a reload; a series of "wired=true"
// entries proves it's just repeated Settings-panel visits (harmless).
console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored }));
function showLinuxFallback(message) {
// Deliberately leaves channelSelect ENABLED: on Linux "unsupported"
// usually just means "the channel isn't Nightly yet", and the dropdown
// is the only way to switch to Nightly. Disabling it would trap the
// user on whatever channel they booted with. Only the check button and
// the note reflect the unsupported state.
if (linuxNote) linuxNote.classList.remove('hidden');
channelSelect.disabled = true;
checkBtn.disabled = true;
// Reset the button out of any leftover "Restart now" state (e.g. an
// update was staged on nightly, then the user switched channels).
checkBtn.textContent = 'Check for updates';
checkBtn.dataset.mode = 'check';
statusEl.textContent = message || 'Auto-update is not available on this platform.';
}
@@ -262,61 +339,140 @@ export function setupAppUpdates() {
} catch (_) { return 'never'; }
}
// Render one status object. Always keeps the current version + channel
// visible and appends what's happening, so the download progress never
// obscures which build you're on.
function renderFrom(s, extra) {
// Diagnostic trace: log the raw status object before any branching —
// auto-captured by diagnostics.js's console wrap into the exportable
// ring buffer, so "Export Diagnostics" in this same Settings → System
// panel captures exactly what the app saw and decided, not just what
// the UI showed. Deduped so a steady poll doesn't flood the ring buffer
// (see _appUpdateLastRenderLog); a real state/percent change differs and
// still logs; the structured contribute() snapshot below is unconditional.
const logKey = `${JSON.stringify(s)}|${extra || ''}`;
if (logKey !== _appUpdateLastRenderLog) {
_appUpdateLastRenderLog = logKey;
console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : '');
}
const view = _appUpdateStatusView(s, {
channelValue: channelSelect.value,
canApply: typeof updateApi.apply === 'function',
fmtTimestamp,
});
if (view.kind === 'unavailable') { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
if (view.kind === 'unsupported') {
showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.');
return;
}
// Healthy for the current channel — clear any "unsupported" UI left
// over from a prior channel selection.
if (linuxNote) linuxNote.classList.add('hidden');
// The button is a little state machine (dataset.mode drives the click
// handler's restart-vs-check branch); the channel selector locks only
// while a check/download is active. See _appUpdateStatusView.
channelSelect.disabled = view.channelDisabled;
checkBtn.textContent = view.btnLabel;
checkBtn.dataset.mode = view.btnMode;
checkBtn.disabled = view.btnDisabled;
const line = view.line;
statusEl.textContent = extra ? `${extra} · ${line}` : line;
// Live structured snapshot (overwrites, not a log) via the existing
// diagnostics contribute() API — 'audio_engine' is feedBack-desktop's
// own registered plugin id, so the server's diagnostics export won't
// filter it out. Always current, no scrolling through console history
// needed to answer "what does the app think is going on right now."
try {
window.feedBack?.diagnostics?.contribute('audio_engine', {
update: {
channel: s.channel || channelSelect.value,
status: s.status,
currentVersion: s.currentVersion ?? null,
lastChecked: s.lastChecked ?? null,
percent: typeof s.percent === 'number' ? s.percent : null,
message: s.message ?? null,
rendered: line,
ts: Date.now(),
},
});
} catch (_) { /* diagnostics.js not loaded — never let this break rendering */ }
// A download runs in the background (the check returns immediately), so
// poll for the terminal state rather than relying solely on a one-shot
// "downloaded" event that could be missed or arrive out of order.
if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy();
}
function renderStatus(extra) {
try {
// Wrap in Promise.resolve so a future getStatus() that returns
// synchronously won't blow up on .then().
void Promise.resolve(updateApi.getStatus()).then((s) => {
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
if (s.status === 'unsupported' || s.platform === 'linux') {
showLinuxFallback('Auto-update is not available on Linux.');
return;
}
if (s.status === 'error') {
const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.';
statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg;
return;
}
const parts = [
`Version ${s.currentVersion || '?'}`,
`channel ${s.channel || channelSelect.value}`,
`last checked ${fmtTimestamp(s.lastChecked)}`,
];
statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · ');
}).catch((e) => {
console.warn('[updater] getStatus failed:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
});
void Promise.resolve(updateApi.getStatus())
.then((s) => renderFrom(s, extra))
.catch((e) => {
console.warn('[updater] getStatus failed:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
});
} catch (e) {
console.warn('[updater] getStatus threw:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
}
}
if (isLinux) {
showLinuxFallback('Auto-update is not available on Linux.');
// Keep main informed of the persisted channel even on Linux so
// cross-platform reasoning about the channel stays consistent.
// setChannel() may return a Promise — chain .catch() so a rejected
// promise doesn't surface as an unhandled rejection.
try {
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
console.warn('[updater] setChannel(linux) failed:', e);
// While a download (or check) is active, re-read the authoritative status
// every ~1.5s and stop once it settles (downloaded / idle / error). This is
// what guarantees the panel leaves "downloading… 100%" and lands on "update
// ready" (or surfaces a swap error) even if the completion event is lost.
function pollWhileBusy() {
if (_appUpdatePollTimer) return;
_appUpdatePollTimer = setInterval(() => {
void Promise.resolve(updateApi.getStatus()).then((s) => {
renderFrom(s);
const st = s && s.status;
if (st !== 'downloading' && st !== 'checking') {
clearInterval(_appUpdatePollTimer);
_appUpdatePollTimer = null;
}
}).catch(() => {
clearInterval(_appUpdatePollTimer);
_appUpdatePollTimer = null;
});
} catch (e) {
console.warn('[updater] setChannel(linux) threw:', e);
}
return;
}, 1500);
}
// Inform main of the persisted channel on each load. setChannel() on
// main is idempotent when the channel already matches.
try {
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
console.warn('[updater] setChannel(initial) failed:', e);
});
} catch (e) {
console.warn('[updater] setChannel(initial) threw:', e);
// Inform main of the persisted channel — but ONLY the first time this page
// wires up, not on every loadSettings() re-render. This used to run
// unconditionally on every call and was caught (via Export Diagnostics)
// stomping an in-flight check/download: a redundant setChannel() call
// mid-download bumps main's checkGeneration and resets progress state,
// so the download silently loses its ability to report completion even
// though the file swap itself still happens in the background. Once
// wired, the channel select's own 'change' handler is the only thing
// that needs to tell main about a channel switch.
if (!_appUpdatesWired) {
try {
// Render from THIS call's own result (same reasoning as the check
// button and the 'change' handler below), not just catch its
// errors. The unconditional renderStatus() at the bottom of this
// function fires a SEPARATE getStatus() round-trip immediately
// after — if that resolves before main has processed this
// setChannel() (e.g. main is still on its 'stable' boot default),
// the UI would render 'unsupported' and — since this call's own
// eventual success was never rendered — get stuck there
// permanently, even once main correctly switches channel a moment
// later. Rendering here too means whichever of the two calls
// resolves LAST wins and shows the true state, regardless of
// which order they land in.
void Promise.resolve(updateApi.setChannel(stored)).then((result) => {
_appUpdateAckedChannel = stored;
renderFrom(result);
}).catch((e) => {
console.warn('[updater] setChannel(initial) failed:', e);
});
} catch (e) {
console.warn('[updater] setChannel(initial) threw:', e);
}
}
if (!_appUpdatesWired) {
@@ -326,56 +482,82 @@ export function setupAppUpdates() {
channelSelect.addEventListener('change', async () => {
const val = channelSelect.value;
if (!APP_UPDATE_CHANNELS.includes(val)) return;
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
console.log('[update-diag] user switched channel to', val);
try {
// Await setChannel so the status line reflects what actually
// happened — rendering "Channel set" unconditionally would
// mislead users when the IPC rejects.
await Promise.resolve(updateApi.setChannel(val));
renderStatus(`Channel set to ${val}.`);
// Render from setChannel()'s own return value (same reasoning
// as the check button: it's computed synchronously at the
// moment of the switch, so it can't be stale, unlike a
// follow-up getStatus() call).
const result = await Promise.resolve(updateApi.setChannel(val));
// Only persist once main has actually acknowledged the switch —
// a failed setChannel() must never leave localStorage (or the
// dropdown) ahead of what main is really using.
_appUpdateAckedChannel = val;
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
renderFrom(result, `Channel set to ${val}.`);
} catch (e) {
console.warn('[updater] setChannel failed:', e);
channelSelect.value = _appUpdateAckedChannel ?? 'stable';
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
}
});
checkBtn.addEventListener('click', async () => {
// In restart mode (set by renderFrom once an update is staged) the
// button applies the update instead of checking again.
if (checkBtn.dataset.mode === 'restart') {
console.log('[update-diag] user clicked Restart now');
checkBtn.disabled = true;
checkBtn.textContent = 'Restarting…';
try {
const r = await updateApi.apply();
if (r?.status === 'error') {
console.warn('[updater] apply returned error:', r.message || 'unknown');
renderFrom(r, 'Restart failed.');
}
// On success the app quits + relaunches — nothing to render.
} catch (e) {
console.warn('[updater] apply failed:', e);
statusEl.textContent = `Restart failed: ${e?.message || e}`;
checkBtn.textContent = 'Restart now';
checkBtn.disabled = false;
}
return;
}
console.log('[update-diag] user clicked Check for updates');
checkBtn.disabled = true;
statusEl.textContent = 'Checking for updates…';
let reEnableBtn = true;
let result;
try {
const result = await updateApi.checkNow();
const status = result?.status || 'unknown';
let msg;
switch (status) {
case 'idle':
msg = "You're on the newest version in this channel.";
break;
case 'downloading':
msg = 'Update available — downloading…';
break;
case 'downloaded':
msg = 'Update downloaded — restart to apply.';
break;
case 'unsupported':
reEnableBtn = false;
showLinuxFallback('Auto-update is not available on Linux.');
return;
case 'error':
msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`;
break;
default:
msg = `Update check returned: ${status}`;
}
renderStatus(msg);
// The Linux check returns immediately (any download runs in the
// background).
result = await updateApi.checkNow();
} catch (e) {
console.warn('[updater] checkNow failed:', e);
statusEl.textContent = `Update check failed: ${e?.message || e}`;
} finally {
if (reEnableBtn) checkBtn.disabled = false;
checkBtn.disabled = false;
return;
}
// Render straight from checkNow()'s own return value rather than a
// follow-up getStatus() call. checkNow() computes that value
// synchronously at the moment it decides the outcome, so it can't
// be stale; a separate getStatus() round-trip right after it can
// race with anything that resets state in between (a concurrent
// channel switch, another in-flight check settling) and show a
// blanked "up to date · last checked never" even though this check
// just succeeded.
renderFrom(result);
});
// Main-process events (checkNow/download decisions in update-manager.ts)
// are invisible to this page's console — forward them into it so a
// single "Export Diagnostics" click captures both sides of the story.
if (typeof updateApi.onDiag === 'function') {
updateApi.onDiag((payload) => {
console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : '');
});
}
_appUpdatesWired = true;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+100
View File
@@ -0,0 +1,100 @@
// Generic gamepad menu navigation: Tab-order emulation.
//
// Every v3 screen except v3-songs (which has its own 2D grid nav) is built from
// real, natively-focusable <button>/<a> elements, so real Tab/Shift+Tab and real
// Enter/Space already work perfectly. The gap is that nothing ever calls
// .focus() on anything, and gamepad.js only ever synthesizes Arrow keydowns —
// it never sends Tab (browsers don't focus-traverse on a synthetic Tab anyway).
// This fills that gap by moving focus through the same set of elements Tab
// already visits, one step per Arrow press, treating Down/Right as "next" and
// Up/Left as "previous".
//
// Gated on !e.isTrusted so this NEVER touches real keyboard/mouse users — it
// only ever reacts to gamepad.js's synthetic events. Also bails whenever a more
// specific handler already claimed the key (songs.js's grid nav, shortcuts.js's
// legacy library arrow-nav, or the shortcuts registry's player-scope seek
// shortcuts all call preventDefault() before this listener runs, since script
// tag order puts them earlier in the document than this file).
(function () {
'use strict';
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
var ARROWS = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
var TEXT_INPUT_TYPES = ['text', 'search', 'email', 'url', 'tel', 'password', 'number'];
function visible(el) {
return el.offsetParent !== null;
}
function focusScopeRoot() {
var modal = document.querySelector('[role="dialog"][aria-modal="true"], .feedBack-modal');
if (modal && visible(modal)) return [modal];
var nav = document.getElementById('v3-nav');
var screen = document.querySelector('.screen.active');
return [nav, screen].filter(Boolean);
}
function focusables() {
var roots = focusScopeRoot();
var els = [];
roots.forEach(function (root) {
Array.prototype.push.apply(els, root.querySelectorAll(FOCUSABLE));
});
return els.filter(visible);
}
function isTextInput(el) {
if (!el) return false;
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
return el.tagName === 'INPUT' && TEXT_INPUT_TYPES.includes((el.type || 'text').toLowerCase());
}
document.addEventListener('keydown', function (e) {
if (e.isTrusted || e.defaultPrevented) return;
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
// Chromium doesn't run the native "Enter/Space activates the focused
// link/button" default action for untrusted synthetic keydowns, even
// when dispatched straight at the focused element (confirmed by
// testing) — so without this, a focused sidebar link or dashboard
// button just sits there forever. click() works for untrusted events.
var active = document.activeElement;
if (active && active !== document.body && !isTextInput(active)) active.click();
return;
}
if (e.key === 'Escape') {
// Only 'player' and 'settings' have a registered Escape shortcut
// (shortcuts.js); every other screen (v3-songs, v3-plugins,
// v3-playlists, ...) leaves B with nothing to do — confirmed on-device,
// players get stuck unable to leave the library or any other screen.
// The app never pushes history entries on navigation (shell.js
// deliberately doesn't reflect screen changes into location.hash), so
// history.back() isn't a real "undo the last screen" — a fixed target
// is. Prefer an existing in-screen back button if one is visible
// (reuses each screen's own drill-down logic for free: v3-songs'
// artist/album pages, v3-playlists' list<->detail view), else fall
// back to the main menu, matching the direct showScreen() call the
// settings Escape shortcut already uses.
// querySelector alone would only ever look at the first match in
// DOM order across all three selectors — screens stay in the DOM
// (hidden, not removed) when you navigate away, so a hidden back
// button from a screen you're not on can sort before the visible
// one that actually applies. Check every match for visibility.
var backBtns = document.querySelectorAll('[data-ap-back], [data-albums-back], #v3-pl-back');
var backBtn = Array.prototype.find.call(backBtns, visible);
if (backBtn) backBtn.click();
else if (window.showScreen) window.showScreen('v3-home');
return;
}
var dir = ARROWS[e.key];
if (!dir) return;
var els = focusables();
if (!els.length) return;
var idx = els.indexOf(document.activeElement);
var next = idx === -1 ? 0 : Math.max(0, Math.min(els.length - 1, idx + dir));
els[next].focus();
});
})();
+195
View File
@@ -0,0 +1,195 @@
// Gamepad/controller support.
//
// Rather than a parallel gamepad->action mapping table, this polls
// navigator.getGamepads() and dispatches synthetic keydown events onto
// document with the same key/code pairs a physical keyboard would send.
// static/js/shortcuts.js's existing dispatcher (scope checks, text-field/
// modal guards, library grid nav, player shortcuts) handles the rest.
//
// Steam Deck: Steam Input re-emits the Deck's controls as a standard
// XInput-style virtual pad (both in Gaming Mode and in Desktop Mode when
// launched via a non-Steam shortcut with a controller template), so this
// reports mapping: 'standard' and the button layout below lines up with
// the Deck's physical ABXY. If a pad reports a non-standard mapping
// (e.g. raw HID with no Steam Input in between), this no-ops rather than
// guessing button order.
//
// Plain non-module script; degrades to a no-op without the Gamepad API.
(function () {
'use strict';
if (typeof navigator === 'undefined' || !navigator.getGamepads) return;
var BUTTON_KEYS = {
// Bottom face button (Xbox A / PS Cross "X") — play/pause on the player
// screen; also activates the currently-selected library card, since
// Space is already treated as an activation key there alongside Enter.
0: { key: ' ', code: 'Space' },
1: { key: 'Escape', code: 'Escape' }, // Xbox B / PS Circle
// 2 (Xbox X / PS Square) intentionally unmapped — undecided.
};
var RAIL_REVEAL_BUTTON = 3; // Y — reveals the player screen's left tool rail
// The player rail (#v3-player-rail) has no keyboard shortcut to reuse — it's
// shown via CSS on #v3-railzone:hover or :focus-within (see v3.css). So
// instead of a synthetic keydown, this directly focuses the rail's first
// icon, which the existing :focus-within rule already reveals it for —
// the same mechanism a Tab-key user gets for free.
function revealPlayerRail() {
var active = document.querySelector('.screen.active');
if (!active || active.id !== 'player') return;
var icon = document.querySelector('#v3-player-rail .v3-rail-icon');
if (icon) icon.focus();
}
var DPAD_BUTTONS = {
12: { key: 'ArrowUp', code: 'ArrowUp' },
13: { key: 'ArrowDown', code: 'ArrowDown' },
14: { key: 'ArrowLeft', code: 'ArrowLeft' },
15: { key: 'ArrowRight', code: 'ArrowRight' },
};
var STICK_DEADZONE = 0.5;
var REPEAT_DELAY_MS = 400;
var REPEAT_INTERVAL_MS = 120;
var polling = false;
var buttonWasDown = {}; // index -> bool, for edge-detection (no repeat)
var dirWasDown = {}; // 'up'/'down'/'left'/'right' -> bool
var dirRepeatAt = {}; // 'up'/'down'/'left'/'right' -> timestamp of next repeat
var connectedIndices = {}; // gamepad.index -> true, tracks which slots we've announced
function fireKey(spec) {
// Dispatch on the focused element (falling back to document when nothing
// is focused), not document itself. document.activeElement is always an
// ancestor-inclusive descendant of document, so this still bubbles up
// through every existing document-level listener exactly as before — but
// now a focused <button>/<a> also gets its native Enter/Space activation
// (which never fires for a document-targeted event, since that native
// behavior is wired to the genuinely-focused element receiving the key),
// and any element-scoped keydown handler sees it too.
(document.activeElement || document).dispatchEvent(new KeyboardEvent('keydown', {
key: spec.key, code: spec.code, bubbles: true, cancelable: true,
}));
}
function pollButtons(gp) {
for (var i = 0; i < gp.buttons.length; i++) {
var down = gp.buttons[i].pressed;
if (down && !buttonWasDown[i]) {
if (i === RAIL_REVEAL_BUTTON) revealPlayerRail();
else if (BUTTON_KEYS[i]) fireKey(BUTTON_KEYS[i]);
}
buttonWasDown[i] = down;
}
}
function stickDirections(gp) {
var x = gp.axes[0] || 0;
var y = gp.axes[1] || 0;
return {
left: x < -STICK_DEADZONE,
right: x > STICK_DEADZONE,
up: y < -STICK_DEADZONE,
down: y > STICK_DEADZONE,
};
}
function pollDirection(name, spec, down, now) {
var wasDown = !!dirWasDown[name];
if (down && !wasDown) {
fireKey(spec);
dirRepeatAt[name] = now + REPEAT_DELAY_MS;
} else if (down && wasDown && now >= (dirRepeatAt[name] || Infinity)) {
fireKey(spec);
dirRepeatAt[name] = now + REPEAT_INTERVAL_MS;
}
dirWasDown[name] = down;
}
function pollDpad(gp, now) {
var stick = stickDirections(gp);
Object.keys(DPAD_BUTTONS).forEach(function (idx) {
var spec = DPAD_BUTTONS[idx];
var name = spec.key.replace('Arrow', '').toLowerCase();
var down = (gp.buttons[idx] && gp.buttons[idx].pressed) || stick[name];
pollDirection(name, spec, down, now);
});
}
// A disconnected gamepad's slot stays in the array (gp.connected flips to
// false) rather than being removed — a plain truthiness check on the array
// entry treats a stale, frozen-state disconnected pad as "still there"
// forever, which both swallows the disconnect notice and (if the real
// reconnected pad lands at a different index) reads dead input forever.
function firstLiveStandardPad() {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (var i = 0; i < pads.length; i++) {
var p = pads[i];
if (p && p.connected && p.mapping === 'standard') return p;
}
return null;
}
// Same standard-mapping filter as firstLiveStandardPad — otherwise a
// still-connected non-standard raw mirror (or the real pad simply
// reporting a different mapping) can mask the actual pad's disconnect:
// the toast never fires and polling never stops, even though the pad
// this module can act on is gone.
function anyLiveStandardPad() {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (var i = 0; i < pads.length; i++) {
var p = pads[i];
if (p && p.connected && p.mapping === 'standard') return true;
}
return false;
}
function tick() {
var gp = firstLiveStandardPad();
if (gp) {
pollButtons(gp);
pollDpad(gp, performance.now());
}
if (polling) requestAnimationFrame(tick);
}
function notify(title, icon) {
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
window.fbNotify.show({ title: title, icon: icon, accent: '#0ea5e9', durationMs: 3000 });
}
}
window.addEventListener('gamepadconnected', function (e) {
var idx = e.gamepad && e.gamepad.index;
// Non-standard slots (raw HID mirrors, or anything this module can't
// safely act on) are never tracked/toasted/polled for — only ever
// treat a standard-mapped pad as "a controller connected". Keeping a
// non-standard slot out of connectedIndices also keeps it out of
// anyLiveStandardPad's count, so it can't mask a real disconnect.
if (!e.gamepad || e.gamepad.mapping !== 'standard') return;
if (connectedIndices[idx]) return; // already-announced slot re-firing (focus regain, etc.)
// On the Deck, Steam Input mirrors a real pad with 1-2 virtual XInput
// slots of its own (same physical button presses, extra indices) — only
// toast for the first slot seen so plugging in one controller doesn't
// spam three "connected" notices.
var isFirstSlot = Object.keys(connectedIndices).length === 0;
connectedIndices[idx] = true;
if (isFirstSlot) notify('Controller connected', '🎮');
buttonWasDown = {};
dirWasDown = {};
dirRepeatAt = {};
if (!polling) {
polling = true;
requestAnimationFrame(tick);
}
});
window.addEventListener('gamepaddisconnected', function (e) {
var idx = e.gamepad && e.gamepad.index;
delete connectedIndices[idx];
if (!anyLiveStandardPad()) {
polling = false;
notify('Controller disconnected', '🔌');
}
});
})();
+11 -3
View File
@@ -133,6 +133,7 @@
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
<script type="module" src="/static/capabilities/visualization.js"></script>
<script type="module" src="/static/capabilities/chart-transform.js"></script>
<script type="module" src="/static/capabilities/note-detection.js"></script>
<script type="module" src="/static/capabilities/midi-input.js"></script>
<script type="module" src="/static/capabilities/interface-scale.js"></script>
@@ -326,7 +327,7 @@
<section>
<details>
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
<span>Tuning</span>
<span id="filter-tunings-label">Tuning</span>
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
</summary>
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
@@ -741,6 +742,7 @@
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
<option value="nightly">Nightly</option>
</select>
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
@@ -749,8 +751,8 @@
</div>
<p id="app-update-status" class="text-xs text-gray-500 fb-srow-wide">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 fb-srow-wide">
Auto-update is not available on Linux
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
Auto-update on Linux only works for the AppImage build on the Nightly channel
<a href="https://github.com/got-feedBack/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download other versions from GitHub Releases</a>.
</p>
</div>
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
@@ -1192,6 +1194,10 @@
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="v3-pop-pin" title="Select an arrangement to make it the default"></button>
</span>
</div>
<div class="v3-pop-row hidden" id="v3-drum-part-row">
<span class="v3-pop-label">Drum part</span>
<select id="drum-part-select" onchange="changeDrumPart(this.value)" class="v3-pop-select max-w-[130px]" title="Which drum chart to play — a song can carry several"></select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="mastery-slider-label">Difficulty</span>
<span class="flex items-center gap-2">
@@ -1291,6 +1297,7 @@
<script defer src="/static/v3/theme-core.js"></script>
<script defer src="/static/v3/progression-core.js"></script>
<script defer src="/static/v3/notifications.js"></script>
<script defer src="/static/v3/gamepad.js"></script>
<script defer src="/static/v3/profile.js"></script>
<script defer src="/static/v3/progress.js"></script>
<script defer src="/static/v3/shop.js"></script>
@@ -1321,6 +1328,7 @@
the cover picker (window.__fbOpenImagePicker). -->
<script defer src="/static/v3/image-picker.js"></script>
<script defer src="/static/v3/songs.js"></script>
<script defer src="/static/v3/gamepad-nav.js"></script>
<script defer src="/static/v3/lessons.js"></script>
<script defer src="/static/v3/dashboard.js"></script>
<script defer src="/static/v3/settings.js"></script>
+236 -4
View File
@@ -53,12 +53,192 @@
return (m && m.index != null) ? m.index : null;
}
// ── Playlist tuning check ────────────────────────────────────────────────
// Playlists are commonly grouped BY TUNING so a practice run needs no
// retune mid-session (retuning a bass is minutes of settling, and detuning
// far on standard gauges goes floppy). A playlist built before the tuning
// filter knew about your instrument can hold songs you can't actually play
// without stopping. This flags them. It is READ-ONLY: nothing here edits a
// playlist — removal is a separate, explicit, itemised action.
// Pick the indexed perspective that matches the player's live instrument.
// #1003 supplies bass-specific columns; when a song has no bass chart we
// deliberately fall back to the historical song-level guitar tuning.
function rowTuningForCheck(s) {
let wantsBass = false;
try {
const wt = window.feedBack && window.feedBack.workingTuning;
const cur = wt && typeof wt.get === 'function' ? wt.get() : null;
wantsBass = !!cur && cur.instrument === 'bass';
} catch (_) { /* capability errors degrade to the song-level tuning */ }
const hasBassTuning = wantsBass && !!s.bass_tuning_offsets;
return {
offsets: hasBassTuning
? s.bass_tuning_offsets : (s.tuning_offsets || s.tuning_name),
// The selected bass perspective uses bass base pitches. A bass-only
// fallback row does too; every other fallback is the lead chart.
isBass: hasBassTuning || !!s.bass_only,
};
}
// A coverage report says "not covered" BOTH for a real mismatch and for
// "I couldn't work it out" (missing settings/tuner data → an all-empty
// report). Only a report carrying an actual reason — named string changes,
// a reference-pitch gap, or too few strings — is a mismatch. An unexplained
// not-covered is UNKNOWN. A false "wrong tuning" on a hand-curated playlist
// costs more trust than saying nothing.
function tuningStateFromReport(rep) {
if (!rep) return 'unknown';
if (rep.covered) return 'match';
if (rep.cantCover || rep.reference
|| (Array.isArray(rep.retune) && rep.retune.length)) return 'mismatch';
return 'unknown';
}
// Score every row. Returns null when the host exposes no tuning perspective
// at all (no working-tuning capability / no tuner coverage) — the caller
// then renders the playlist exactly as before rather than claiming anything.
async function checkPlaylistTuning(songs) {
const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
const hasWT = window.feedBack && window.feedBack.workingTuning
&& typeof window.feedBack.workingTuning.get === 'function';
if (typeof cov !== 'function' || !hasWT) return null;
const parse = window.parseRawTuningOffsets;
const out = [];
for (const s of songs || []) {
const t = rowTuningForCheck(s);
const offs = (typeof parse === 'function') ? parse(t.offsets) : null;
if (!offs || !offs.length || offs.some((n) => !isFinite(n))) {
out.push({ song: s, state: 'unknown' });
continue;
}
let rep = null;
try {
rep = await cov({
tuning: offs, stringCount: offs.length,
arrangement: t.isBass ? 'Bass' : 'Lead',
});
} catch (_) { rep = null; }
out.push({ song: s, state: tuningStateFromReport(rep) });
}
return out;
}
// Colour + a TEXT marker per state — unknown is deliberately neutral-and-
// dimmed rather than amber, because "I couldn't check this" is a different
// claim from "this is the wrong tuning" and must not read as the latter.
function paintTuningChip(chip, state) {
if (!chip) return;
if (chip.dataset.baseTitle == null) chip.dataset.baseTitle = chip.getAttribute('title') || '';
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400', 'opacity-60');
chip.classList.add(state === 'match' ? 'bg-emerald-500'
: state === 'mismatch' ? 'bg-amber-400' : 'bg-fb-mid');
if (state === 'unknown') chip.classList.add('opacity-60');
chip.setAttribute('title', chip.dataset.baseTitle + (state === 'match'
? ' — matches your tuning'
: state === 'mismatch' ? ' — needs a retune'
: ' — no tuning data, not checked'));
// Never signal by colour alone.
const mark = state === 'mismatch' ? ' ⚠' : state === 'unknown' ? ' ?' : '';
let m = chip.querySelector('[data-tuning-mark]');
if (!m) {
m = document.createElement('span');
m.setAttribute('data-tuning-mark', '');
chip.appendChild(m);
}
m.textContent = mark;
}
function tuningSummaryHtml(results) {
const total = results.length;
if (!total) return '';
const mism = results.filter((r) => r.state === 'mismatch').length;
const unk = results.filter((r) => r.state === 'unknown').length;
// Plain gap-3 rather than gap-x-3/gap-y-2: the axis-specific pair isn't
// in the committed tailwind.min.css, and regenerating it is not
// reproducible outside CI (autoprefixer/caniuse drift changes unrelated
// bytes), so the summary bar stays within the shipped class set.
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-3 ';
if (!mism) {
return '<div class="' + box + 'border-fb-good/40 bg-fb-good/30 text-fb-good">' +
'<span>✓ All ' + total + ' songs are in your tuning.</span>' +
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data).</span>' : '') +
'</div>';
}
return '<div class="' + box + 'border-amber-400/40 bg-amber-400/10 text-fb-text">' +
'<span><strong>' + mism + '</strong> of ' + total + ' songs aren\'t in your tuning.</span>' +
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data) — left alone.</span>' : '') +
'<span class="flex-1"></span>' +
'<button id="v3-pl-tune-only" class="text-xs px-2 py-1 rounded border border-fb-border text-fb-textDim hover:text-fb-text" aria-pressed="false">Show only these</button>' +
'<button id="v3-pl-tune-remove" class="text-xs px-2 py-1 rounded border border-amber-400/40 text-fb-text hover:bg-fb-card">Remove them…</button>' +
'</div>';
}
// Run the check and wire its affordances. Read-only: the only mutation is
// the explicit, itemised, confirmed removal below.
async function applyTuningCheck(root, pl, pid, rerender) {
const host = root.querySelector('#v3-pl-tuning');
const listEl = root.querySelector('#v3-pl-songs');
if (!host || !listEl) return;
const results = await checkPlaylistTuning(pl.songs);
if (!results) return; // no perspective → say nothing
const rows = listEl.querySelectorAll('li[data-fn]');
results.forEach((r, i) => {
const li = rows[i];
if (!li) return;
li.setAttribute('data-tuning-state', r.state);
paintTuningChip(li.querySelector('[data-tuning-chip]'), r.state);
});
host.innerHTML = tuningSummaryHtml(results);
const onlyBtn = host.querySelector('#v3-pl-tune-only');
onlyBtn?.addEventListener('click', () => {
const on = onlyBtn.getAttribute('aria-pressed') !== 'true';
onlyBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
onlyBtn.textContent = on ? 'Show all' : 'Show only these';
rows.forEach((li) => {
li.classList.toggle('hidden', on && li.getAttribute('data-tuning-state') !== 'mismatch');
});
});
host.querySelector('#v3-pl-tune-remove')?.addEventListener('click', async () => {
// Name every song BEFORE removing anything — a curated playlist is
// user data, so the confirm has to be a list, not a count.
const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song);
if (!doomed.length) return;
const names = doomed.map((s) => '<div>• ' + esc(s.title || s.filename) + '</div>').join('');
const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's')
+ ' from "' + esc(pl.name) + '"?'
// Bulleted with a literal •, and sized with max-h-32, so the
// confirm needs no Tailwind class the committed CSS lacks —
// regenerating tailwind.min.css is not reproducible off CI.
+ '<div class="mt-2 text-xs max-h-32 overflow-y-auto">' + names + '</div>'
+ '<p class="text-xs text-fb-textDim mt-2">They stay in your library — only this playlist changes, and you can add them back.</p>';
const ok = (typeof window.uiConfirm === 'function')
? await window.uiConfirm({
title: 'Remove mismatched songs?', html: msg,
confirmText: 'Remove ' + doomed.length, cancelText: 'Cancel', danger: true,
})
: window.confirm('Remove ' + doomed.length + ' song(s) from "' + pl.name + '"?\n\n'
+ doomed.map((s) => '• ' + (s.title || s.filename)).join('\n')
+ '\n\nThey stay in your library.');
if (!ok) return;
for (const s of doomed) {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(s.filename),
{ method: 'DELETE' });
}
rerender();
});
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
// The chip carries its own tuning so the post-paint check can colour it
// in place (green = play it now, amber = needs a retune, dimmed ? =
// couldn't tell) without re-rendering the list.
const tuning = s.tuning_name
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
? '<span data-tuning-chip class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm" title="' + esc(s.tuning_name) + '">' + esc(s.tuning_name) + '</span>' : '';
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
// the work's current keeper when the pinned file is gone) with its
@@ -144,19 +324,29 @@
const root = document.getElementById('v3-playlists');
if (!root) return;
const lists = (await jget('/api/playlists')) || [];
// Drag-to-reorder is for user playlists only — system ones (Saved for
// Later) stay pinned first by the server ordering.
const userCount = lists.filter((p) => !p.system_key).length;
root.innerHTML =
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="flex items-center justify-end gap-2 mb-6">' +
// Sort AZ: clears the manual (drag) order server-side. Only worth
// showing once there are two user playlists to order.
(userCount > 1
? '<button id="v3-pl-sort-az" title="Sort playlists alphabetically (clears manual order)" class="text-sm text-fb-textDim hover:text-fb-text px-2">Sort AZ</button>' : '') +
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
// per track — same machinery as a playlist, kind='album'.
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
'</div>' +
(lists.length
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
? '<div id="v3-pl-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '"' + (p.system_key ? '' : ' draggable="true"') + ' class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
playlistCoverHtml(p) +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
'<div class="flex items-center gap-1">' +
'<span class="flex-1 min-w-0 text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</span>' +
(p.system_key ? '' : '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>') +
'</div>' +
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>'
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
@@ -173,8 +363,44 @@
await jsend('POST', '/api/playlists', { name, kind: 'album' });
renderPlaylists();
});
root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => {
await jsend('POST', '/api/playlists/sort-alpha');
renderPlaylists();
});
root.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
// Drag-reorder of the playlist cards themselves (mirrors wireSongRows).
// Only user playlists carry draggable="true"; system cards are neither
// drag sources nor drop targets, so nothing can be inserted ahead of
// them (and the server pins them first regardless).
const grid = root.querySelector('#v3-pl-grid');
if (grid) {
let dragEl = null;
grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => {
card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); });
card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); });
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragEl || dragEl === card) return;
// Grid tiles flow left→right then wrap, so the insert side
// is horizontal (the song rows' vertical-midpoint idiom,
// rotated); moving to another row targets that row's cards.
const rect = card.getBoundingClientRect();
const after = (e.clientX - rect.left) > rect.width / 2;
card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card);
});
card.addEventListener('drop', async (e) => {
e.preventDefault();
const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]'))
.map((x) => parseInt(x.getAttribute('data-pl'), 10));
await jsend('POST', '/api/playlists/reorder', { order });
// Re-sync from the server: if /reorder was rejected
// (concurrent change) or the request failed, the optimistic
// DOM order would otherwise diverge from what persisted.
renderPlaylists();
});
});
}
}
async function renderPlaylistDetail(pid) {
@@ -226,6 +452,9 @@
'</div>' +
'</div>' +
meter +
// Filled in after paint by applyTuningCheck (async, feature-detected)
// — stays empty when the host exposes no tuning perspective.
(pl.songs.length ? '<div id="v3-pl-tuning"></div>' : '') +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
@@ -275,6 +504,9 @@
});
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
// Post-paint so the list is interactive immediately; a per-song coverage
// call can await the tuner plugin's settings fetch.
if (listEl) applyTuningCheck(root, pl, pid, () => renderPlaylistDetail(pid));
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
if (listEl && isAlbum) {
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
+4455 -4138
View File
File diff suppressed because it is too large Load Diff
+58 -2
View File
@@ -133,6 +133,11 @@
let _lastStingerAt = -Infinity;
let _prevStreak = 0;
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
// Filename of the song song:loaded last reported. An arrangement switch
// re-emits song:loaded for the SAME file (changeArrangement reloads through
// the normal load path), and that must not be mistaken for arriving at the
// venue with a new song — see onSongLoaded.
let _lastSongFile = '';
let _bound = false;
function now() { return Date.now(); }
@@ -478,10 +483,40 @@
}
}
function onSongLoaded() {
// song:loaded for the SAME file is an arrangement switch, not an arrival at
// the venue. changeArrangement() reloads through the normal load path, so
// the event is indistinguishable from a fresh load except by filename.
function isArrangementSwitch(prevFile, nextFile) {
return !!nextFile && nextFile === prevFile;
}
function onSongLoaded(song) {
const file = String((song && song.filename) || '');
const sameSong = isArrangementSwitch(_lastSongFile, file);
_lastSongFile = file;
machine.reset();
_prevStreak = 0;
_lastAccuracyPct = null;
// Switching arrangement is NOT arriving at the venue.
//
// changeArrangement() reloads the song through the same path as a fresh
// load, so highway.js emits song:loaded again — same filename, new
// arrangement. Treated as a new song, that replayed the arrival flyover:
// the camera flew in from the back of the room again mid-set, every time
// the player switched from lead to rhythm. The player is already on
// stage; the room should just carry on.
//
// So keep the video pipeline running and only re-sync the mood: the
// performance restarts, so the loop must follow the reset machine (a
// quiet crossfade), never the intro.
if (sameSong) {
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
return;
}
// A genuinely different song — full teardown.
// Abort any stinger/pending state from the previous song: its ended
// handler must not fade back into the old song's layers.
cancelFade();
@@ -494,7 +529,27 @@
_loadingLoop = null;
_fadingLoop = null;
if (_venueActive && _manifest) {
if (!playIntro()) showLoop(machine.current, FADE_MS);
// The flyover is ARRIVING at the venue, and you arrive once. Songs
// 2..N of a set (a gig / album / playlist) are a NEW song but the
// SAME arrival — the camera should not fly in from the back of the
// room before every track (tester: "it showed the flyover intro
// again" on a gig's second song). Continue the room to the new song's
// loop; only a first-song / standalone arrival flies in.
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
else if (!playIntro()) showLoop(machine.current, FADE_MS);
}
}
// Is this song load a continuation of a play queue (a set already in
// progress), rather than an arrival? True for song 2..N of a gig/album/
// playlist. The queue owns the answer; treat any error / absent queue as
// "not a continuation" so a standalone play still flies in.
function _isSetContinuation() {
try {
const q = window.feedBack && window.feedBack.playQueue;
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
} catch (_) {
return false;
}
}
@@ -651,6 +706,7 @@
bindRuntime,
getState,
celebrate,
isArrangementSwitch,
};
if (root) root.v3VenueCrowd = api;
+37 -3
View File
@@ -18,6 +18,30 @@
let _lastMood = 'idle';
let _bound = false;
// The venue belongs to the SONG player and nowhere else.
//
// isVenueViz() only answers "is Venue the selected visualization" — a global
// preference. It says nothing about what is on screen. Other surfaces borrow
// the same highway_3d renderer (Virtuoso runs its practice charts on it), so
// with Venue selected they inherited the venue backdrop: the crowd and the
// stage showed up behind a chromatic exercise. The viz picker is a
// preference for the player; it is not a licence to paint the venue over
// whatever else happens to be using the renderer.
//
// So gate on both: Venue selected AND the player screen is the one showing.
function isPlayerScreen() {
try {
const active = document.querySelector('.screen.active');
return !!active && active.id === 'player';
} catch (_) {
return false;
}
}
function shouldBeActive() {
return isVenueViz() && isPlayerScreen();
}
function isVenueViz() {
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
const sel = root.v3VenueViz.getSelectedVizId
@@ -146,7 +170,8 @@
function syncViz(vizId) {
const id = String(vizId || '');
if (id === 'venue') {
// Venue selected is necessary but not sufficient — see shouldBeActive.
if (id === 'venue' && isPlayerScreen()) {
activate();
} else {
deactivate();
@@ -192,12 +217,19 @@
if (_active) syncInstrumentPov();
});
sm.on('viz:renderer:ready', () => {
if (isVenueViz()) activate();
if (shouldBeActive()) activate();
else deactivate();
});
sm.on('viz:reverted', () => deactivate());
// Leaving the player tears the venue down; coming back rebuilds it.
// Without this the backdrop followed the renderer onto every other
// surface that borrows it (Virtuoso's practice highway).
sm.on('screen:changed', () => {
if (shouldBeActive()) activate();
else deactivate();
});
}
if (isVenueViz()) activate();
if (shouldBeActive()) activate();
}
function getState() {
@@ -234,6 +266,8 @@
activate,
deactivate,
syncViz,
isPlayerScreen,
shouldBeActive,
onAssetsLoaded,
onAssetsFailed,
onPerformanceState,
@@ -0,0 +1,63 @@
// The window globals are a THIRD-PARTY CONTRACT. Pin them.
//
// Out-of-tree plugins load their screen.js as a CLASSIC script and call these
// as bare globals. Nothing in core reads most of them, so a call-graph scan,
// ESLint's no-undef, and a grep all come back clean while the plugin breaks in
// the field. This is the frontend twin of tests/test_plugin_context_contract.py
// — same reasoning, same literal-list rule.
//
// This guard is retroactive: `esc` was an implicit global back when app.js was
// a classic script, went module-scoped in a9fce29, and got carved into
// js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt
// without it, and the MIDI plugin's device list threw "esc is not defined" for
// testers — reported as "MIDI Access denied", because the ReferenceError landed
// in a try/catch meant for permission failures.
//
// WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from
// app.js would assert the code equals itself. The point is that a human has to
// look at a diff and consciously agree to change the contract.
import { test, expect } from '@playwright/test';
const PLUGIN_GLOBALS = [
'_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl',
'_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal',
'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop',
'deleteSelectedLoop', 'esc', 'exportDiagnostics', 'exportSettings', 'filterFavorites',
'filterLibrary', 'fullRescanLibrary', 'goFavPage', 'handleSliderInput',
'hideScanBanner', 'importSettings', 'loadPlugins', 'loadSavedLoop',
'loadSettings', 'onSectionPracticeModeChange', 'openEditModal', 'persistSetting',
'pickDlcFolder', 'pinCurrentArrangementDefault', 'playSong', 'previewDiagnostics',
'previewEditArt', 'renderGridCards', 'renderTreeInto', 'rescanLibrary',
'retuneSong', 'saveCurrentLoop', 'saveSettings', 'seekBy',
'setAvOffsetMs', 'setFavView', 'setInstrumentPathway', 'setLibView',
'setLibraryProvider', 'setLoopEnd', 'setLoopStart', 'setMastery',
'setSpeed', 'setViz', 'showScreen', 'sortFavorites',
'sortLibrary', 'syncLibrarySong', 'toggleAllArtists', 'toggleAllFavoriteArtists',
'toggleLibFilters', 'togglePlay', 'toggleSectionPracticePopover', 'uiPrompt',
'updatePlugin', 'uploadSongs',
'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage',
];
test('plugin-facing window globals are all callable', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const missing = await page.evaluate(
(names) => names.filter((n) => typeof (window as any)[n] !== 'function'),
PLUGIN_GLOBALS,
);
expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]);
});
// The plugin call site that actually broke: esc() interpolated into a template
// string. A global that exists but doesn't escape is its own bug.
test('window.esc escapes HTML metacharacters', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const escaped = await page.evaluate(() => (window as any).esc('<img src=x onerror=alert(1)>'));
expect(escaped).not.toContain('<img');
expect(escaped).toContain('&lt;');
});
+105
View File
@@ -0,0 +1,105 @@
// Unit tests for the App-updates panel's status → view-model state machine
// (_appUpdateStatusView in static/js/settings.js). settings.js pulls a large
// ES-module graph (highway-colors, library, player-controls), so rather than
// import it, the pure function is sliced out of source and evaluated on its
// own — it's DOM-free by construction, which is the whole point of extracting
// it. The slice marker is asserted so a rename fails loudly instead of testing
// nothing.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'settings.js'), 'utf8');
function extractFn(source, name) {
const marker = `export function ${name}`;
const start = source.indexOf(marker);
assert.notEqual(start, -1, `${name} must exist in settings.js`);
// Skip the parameter list first (it contains a destructured `{ … } = {}`
// default, so the body's opening brace isn't the first `{` after the name).
let pd = 0, i = source.indexOf('(', start);
for (; i < source.length; i++) {
if (source[i] === '(') pd++;
else if (source[i] === ')' && --pd === 0) break;
}
const open = source.indexOf('{', i);
let depth = 0;
for (let j = open; j < source.length; j++) {
if (source[j] === '{') depth++;
else if (source[j] === '}' && --depth === 0) {
return source.slice(start, j + 1).replace('export function', 'function');
}
}
throw new Error(`unbalanced braces extracting ${name}`);
}
const _appUpdateStatusView = new Function(
`${extractFn(SRC, '_appUpdateStatusView')}\nreturn _appUpdateStatusView;`,
)();
const FMT = () => 'just now';
const view = (s, opts) => _appUpdateStatusView(s, { channelValue: 'nightly', fmtTimestamp: FMT, ...opts });
test('null status → unavailable', () => {
assert.deepEqual(_appUpdateStatusView(null), { kind: 'unavailable' });
});
test('unsupported / any linux-platform status → unsupported', () => {
assert.equal(view({ status: 'unsupported', platform: 'linux' }).kind, 'unsupported');
assert.equal(view({ status: 'idle', platform: 'linux' }).kind, 'unsupported',
'a stray platform:linux still routes to the fallback, matching renderFrom');
});
test('idle shows "up to date" with the formatted last-checked time, controls enabled', () => {
const v = view({ status: 'idle', currentVersion: '1.2.3', channel: 'nightly', lastChecked: 123 });
assert.equal(v.kind, 'status');
assert.equal(v.line, 'Version 1.2.3 · nightly · up to date · last checked just now');
assert.equal(v.btnLabel, 'Check for updates');
assert.equal(v.btnMode, 'check');
assert.equal(v.btnDisabled, false);
assert.equal(v.channelDisabled, false);
});
test('checking and downloading disable the button AND lock the channel selector', () => {
const chk = view({ status: 'checking', currentVersion: '1', channel: 'nightly' });
assert.equal(chk.btnDisabled, true);
assert.equal(chk.channelDisabled, true);
assert.match(chk.line, /checking for updates…$/);
const dl = view({ status: 'downloading', currentVersion: '1', channel: 'nightly', percent: 42 });
assert.equal(dl.btnDisabled, true);
assert.equal(dl.channelDisabled, true);
assert.match(dl.line, /downloading update… 42%$/);
});
test('downloading without a percent falls back to the indeterminate label', () => {
const v = view({ status: 'downloading', currentVersion: '1', channel: 'nightly', percent: null });
assert.match(v.line, /update available — downloading…$/);
});
test('downloaded flips the button to Restart when apply() exists', () => {
const v = view({ status: 'downloaded', currentVersion: '1', channel: 'nightly' }, { canApply: true });
assert.equal(v.btnLabel, 'Restart now');
assert.equal(v.btnMode, 'restart');
assert.equal(v.channelDisabled, false, 'staged is not in-flight — channel stays switchable');
assert.match(v.line, /update ready$/);
});
test('downloaded on an older bridge (no apply) stays a plain check button with text-only guidance', () => {
const v = view({ status: 'downloaded', currentVersion: '1', channel: 'nightly' }, { canApply: false });
assert.equal(v.btnMode, 'check');
assert.equal(v.btnLabel, 'Check for updates');
assert.match(v.line, /update ready — restart to apply$/);
});
test('error surfaces the message, or a generic fallback when absent', () => {
assert.match(view({ status: 'error', currentVersion: '1', channel: 'nightly', message: 'boom' }).line, /update error: boom$/);
assert.match(view({ status: 'error', currentVersion: '1', channel: 'nightly' }).line, /update check failed$/);
});
test('missing version and channel fall back to "?" and the dropdown value', () => {
const v = view({ status: 'idle', lastChecked: 0 }, { channelValue: 'beta' });
assert.match(v.line, /^Version \? · beta · /);
});
+335
View File
@@ -0,0 +1,335 @@
/**
* Tests for career-gig-tuning interstitial logic (feedBack career-gig-tuning).
*
* Failure inputs:
* - pref='specific' + first song no interstitial (specific never needs a tune pause)
* - pref='any' + first song interstitial fires
* - pref='any' + same tuning no interstitial between songs
* - pref='any' + tuning diff interstitial fires
* - holdAutoplay absent interstitial gracefully skipped
*/
'use strict';
const { test, describe } = 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 ROOT = path.join(__dirname, '..', '..');
const SCREEN_JS = fs.readFileSync(
path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8'
);
// ---------------------------------------------------------------------------
// VM harness
// ---------------------------------------------------------------------------
function makeCtx(opts = {}) {
const holdReleaseCalled = { v: false };
const holdSettleCalled = { v: false };
const feedBackBase = {
on: () => {},
emit: () => {},
holdAutoplay: opts.noHoldAutoplay ? undefined : function () {
const release = function () { holdReleaseCalled.v = true; };
release.settle = function () { holdSettleCalled.v = true; };
return release;
},
};
const ctx = vm.createContext({
window: {},
document: {
getElementById: () => null,
readyState: 'complete',
addEventListener: () => {},
},
localStorage: {
_store: {},
getItem(k) { return this._store[k] != null ? this._store[k] : null; },
setItem(k, v) { this._store[k] = String(v); },
},
clearTimeout: () => {},
setTimeout: (fn, ms) => 42,
fetch: () => Promise.resolve({ ok: false, json: async () => ({}) }),
console,
__holdReleaseCalled: holdReleaseCalled,
__holdSettleCalled: holdSettleCalled,
});
// Set window.feedBack inside the context so script-level refs pick it up
ctx.window.feedBack = feedBackBase;
vm.runInContext(SCREEN_JS, ctx);
return ctx;
}
function setRun(ctx, tuning_pref, songs) {
vm.runInContext(`
window.__careerPassportTest.setGigRun({
idx: 0,
tuning_pref: ${JSON.stringify(tuning_pref)},
songs: ${JSON.stringify(songs)},
});
`, ctx);
}
function get(ctx, expr) {
return vm.runInContext(expr, ctx);
}
function callOnLoading(ctx) {
vm.runInContext('window.__careerPassportTest.onGigSongLoading()', ctx);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('career-gig-tuning interstitial', () => {
test('no hold when gig run is null', () => {
const ctx = makeCtx();
vm.runInContext('window.__careerPassportTest.setGigRun(null)', ctx);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song fires interstitial for pref=any', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song fires interstitial for pref=standard', () => {
const ctx = makeCtx();
setRun(ctx, 'standard', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song does NOT fire interstitial for pref=specific:E Standard', () => {
// Failure input: bare 'specific' would pass the old wrong guard `!== 'specific'`
// but is impossible in production. Real value is always 'specific:<name>'.
const ctx = makeCtx();
setRun(ctx, 'specific:E Standard', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('between-songs same tuning: no interstitial', () => {
const ctx = makeCtx();
const songs = [
{ filename: 'a.sloppak', tuning_name: 'E Standard' },
{ filename: 'b.sloppak', tuning_name: 'E Standard' },
];
setRun(ctx, 'any', songs);
vm.runInContext('window.__careerPassportTest.setLastTuning("E Standard")', ctx);
vm.runInContext('window.__careerPassportTest.getGigRun().idx = 1', ctx);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('between-songs tuning change: fires interstitial for pref=any', () => {
const ctx = makeCtx();
const songs = [
{ filename: 'a.sloppak', tuning_name: 'E Standard' },
{ filename: 'b.sloppak', tuning_name: 'Drop D' },
];
setRun(ctx, 'any', songs);
vm.runInContext('window.__careerPassportTest.setLastTuning("E Standard")', ctx);
vm.runInContext('window.__careerPassportTest.getGigRun().idx = 1', ctx);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('lastTuning is updated after onGigSongLoading', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'Drop D' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getLastTuning()'), 'Drop D');
});
test('clearing hold via setTuningHold(null) leaves null', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
vm.runInContext('window.__careerPassportTest.setTuningHold(null)', ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('holdAutoplay unavailable: no interstitial (graceful skip)', () => {
const ctx = makeCtx({ noHoldAutoplay: true });
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
});
// ---------------------------------------------------------------------------
// bookGig — generation guard (F1) and 404-only revert (F2)
// ---------------------------------------------------------------------------
describe('career-gig-tuning bookGig', () => {
// Helper: make a ctx where bookGig is callable.
// fetch is overridable per-test via ctx.fetch.
function makeBookCtx() {
const ctx = vm.createContext({
window: {},
document: {
getElementById: () => null,
readyState: 'complete',
addEventListener: () => {},
},
localStorage: {
_store: {},
getItem(k) { return this._store[k] != null ? this._store[k] : null; },
setItem(k, v) { this._store[k] = String(v); },
},
clearTimeout: () => {},
setTimeout: () => 42,
fetch: null, // set per test
console,
});
ctx.window.feedBack = { on: () => {}, emit: () => {} };
vm.runInContext(SCREEN_JS, ctx);
// Seed _pp so bookGig can find the passport
vm.runInContext(`
window.__careerPassportTest.setView({
instruments: {
guitar: {
passports: [{ genre_key: 'rock', genre: 'Rock' }]
}
}
});
`, ctx);
return ctx;
}
test('F1: stale response from superseded request is discarded — _ppGigProposal keeps new value', async () => {
// Failure input: two requests fire; second completes first; first (stale) must be dropped.
// Without the generation guard, the stale Drop response would overwrite the Standard proposal.
const ctx = makeBookCtx();
let resolveFirst, resolveSecond;
const first = new Promise(r => { resolveFirst = r; });
const second = new Promise(r => { resolveSecond = r; });
let callCount = 0;
ctx.fetch = () => {
callCount++;
return callCount === 1 ? first : second;
};
// Fire first request (drop), don't resolve yet
const p1 = vm.runInContext(`
window.__careerPassportTest.setTuningPref('drop');
window.__careerPassportTest.bookGig('rock');
`, ctx);
// Fire second request (standard) — increments gen
const p2 = vm.runInContext(`
window.__careerPassportTest.setTuningPref('standard');
window.__careerPassportTest.bookGig('rock');
`, ctx);
// Resolve SECOND first (standard wins)
resolveSecond({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'standard.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'standard' }) });
await p2;
// Now resolve stale FIRST (drop) — must be discarded
resolveFirst({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'drop.sloppak', tuning_name: 'Drop D' }], tuning_pref: 'drop' }) });
await p1;
// _ppGigProposal must reflect the second (standard) response, not the stale first.
// getProposal() exposes _ppGigProposal via the test seam.
const proposal = vm.runInContext('window.__careerPassportTest.getProposal()', ctx);
// If the generation guard is absent, stale drop overwrites standard → songs[0] is drop.sloppak
assert.ok(
proposal === null || proposal.songs[0].filename !== 'drop.sloppak',
'stale drop response must not overwrite the winning standard proposal'
);
});
test('F2: 500 error keeps user pref — only 404 reverts to any', async () => {
// Failure input: saved pref 'drop', server returns 500 → without fix, pref silently becomes 'any'
const ctx = makeBookCtx();
ctx.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
assert.equal(pref, 'drop', '500 error must not reset pref to any');
});
test('F2: 404 still reverts pref to any', async () => {
const ctx = makeBookCtx();
ctx.fetch = async () => ({ ok: false, status: 404, json: async () => ({ detail: 'No drop songs.' }) });
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
assert.equal(pref, 'any', '404 must revert pref to any');
});
test('F1b: stale 404 json completing after newer booking must not revert newer pref', async () => {
// Failure input: A 404 response is received (first gen check passes), then res.json()
// is awaited. A new booking fires while json() is pending (increments _ppBookGen).
// The error branch MUST re-check gen after json() and NOT revert pref to 'any'.
//
// We simulate the race by having json() bump _ppBookGen synchronously (equivalent to
// a new bookGig call arriving at exactly that moment) before returning a resolved value.
// After the await on json()'s resolved Promise, gen !== _ppBookGen → should bail.
const ctx = makeBookCtx();
ctx.fetch = async () => ({
ok: false,
status: 404,
json: () => {
// Simulate: a new booking fires while json() is in progress
vm.runInContext(
'window.__careerPassportTest.setBookGen(window.__careerPassportTest.getBookGen() + 1);',
ctx
);
vm.runInContext(`window.__careerPassportTest.setTuningPref('standard');`, ctx);
return Promise.resolve({ detail: 'No drop songs.' });
},
});
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
assert.equal(pref, 'standard', 'stale 404 json must not revert newer pref to any');
});
test('F2: closeBook() invalidates in-flight request — overlay stays closed', async () => {
// Failure input: a booking request is in flight (pending fetch), then the user
// closes the poster via the REAL closeBook(). Without ++_ppBookGen in closeBook,
// the pending response resolves and repopulates _ppGigProposal.
//
// Mutation proof: delete `++_ppBookGen` from closeBook() → test goes RED
// (proposal is non-null, assert fails). Restore → GREEN.
const ctx = makeBookCtx();
let resolvePending;
ctx.fetch = () => new Promise(r => { resolvePending = r; });
// Fire a booking — stays pending
vm.runInContext(`window.__careerPassportTest.setTuningPref('any');`, ctx);
const pending = vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
// User closes the poster — call the REAL closeBook() via the test seam
vm.runInContext(`window.__careerPassportTest.closeBook();`, ctx);
// Now resolve the pending fetch with a valid payload
resolvePending({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'a.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'any' }) });
await pending;
// Proposal must remain null — closeBook() incremented _ppBookGen so response was discarded
const proposal = vm.runInContext(`window.__careerPassportTest.getProposal()`, ctx);
assert.equal(proposal, null, 'closeBook() must invalidate in-flight request via ++_ppBookGen');
});
});
+27
View File
@@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
// Degrades without the crowd layer (PR1 not merged / older desktop).
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
});
// feedBack#… (tester): "Venue doesn't load when starting song from passport.
// Loads standard particles." crowd.setManifest(venue) is reached ONLY through
// pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh() (the
// career tab's own reload). A gig navigates away from that tab, so refresh()
// never runs during it — the venue viz turns on but its crowd/stage pack never
// loads. startGig must push the manifest itself after setting the override.
test('startGig pushes the crowd manifest for the gig venue', () => {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8');
const start = src.indexOf('async function startGig(');
assert.ok(start !== -1, 'startGig not found');
const open = src.indexOf('{', src.indexOf(')', start));
let depth = 1, i = open + 1;
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
const fn = src.slice(start, i);
// The override is set, then the manifest must be (re)pushed for it.
const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/);
const pushIdx = fn.search(/pushCrowdManifest\s*\(/);
assert.ok(overrideIdx !== -1, 'startGig must set the venue override');
assert.ok(pushIdx !== -1,
'startGig must push the crowd manifest — refresh() (its only other caller) ' +
'never runs during a gig, so the venue pack would never load');
assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue');
});
+335
View File
@@ -0,0 +1,335 @@
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 { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const CHART_TRANSFORM_JS = path.join(ROOT, 'static', 'capabilities', 'chart-transform.js');
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PLUGIN_ID = 'example_plugin';
const PROVIDER_ID = 'example-transform';
const PROVIDER_LABEL = 'Example Transform';
function makeFakeHighway() {
const calls = { set: [], refresh: 0 };
return {
calls,
setChartTransform(p) { calls.set.push(p); },
refreshChartTransform() { calls.refresh += 1; },
getChartTransform() { return calls.set.length ? calls.set[calls.set.length - 1] : null; },
};
}
function loadChartTransform(options = {}) {
const window = createWindow(options);
// The real bus provides feedBack.on; the harness only has emit →
// dispatchEvent. Shim `on` the same way app.js implements it so the
// module's bus mirroring (song:ready, chart-transform-failed) is live.
window.feedBack.on = (type, handler) => window.addEventListener(type, handler);
if (options.highway) window.highway = options.highway;
if (options.persistedSelection) window.localStorage.setItem(STORAGE_KEY, options.persistedSelection);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(CHART_TRANSFORM_JS, 'utf8'), context, { filename: CHART_TRANSFORM_JS });
return window;
}
function captureEvents(api, eventNames) {
const events = [];
for (const name of eventNames) {
api.subscribe(name, (detail) => events.push(detail));
}
return events;
}
async function registerProvider(api, overrides = {}) {
return api.dispatch({
capability: 'chart-transform', command: 'register-provider',
source: overrides.source || PLUGIN_ID,
payload: {
providerId: overrides.providerId || PROVIDER_ID,
label: overrides.label || PROVIDER_LABEL,
transform: overrides.transform || ((input) => ({ notes: input.notes })),
},
});
}
test('chart-transform domain registers a safe provider-coordinator owner', () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const pipeline = api.inspect('chart-transform');
assert.ok(pipeline, 'chart-transform pipeline exists');
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.chart-transform');
assert.ok(owner, 'core.chart-transform owner registered');
assert.equal(owner.safety, 'safe');
assert.ok(owner.commands.includes('select-provider'));
assert.ok(owner.commands.includes('refresh'));
assert.equal(window.feedBack.chartTransformDomain.version, 1);
});
test('register-provider requires a transform function', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const result = await api.dispatch({
capability: 'chart-transform', command: 'register-provider',
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
});
assert.equal(result.outcome, 'degraded');
assert.match(result.reason, /transform\(input\) function/);
});
test('register + select installs the provider on the highway and persists', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
const events = captureEvents(api, [
'chart-transform:provider-registered',
'chart-transform:transform-changed',
]);
const reg = await registerProvider(api);
assert.equal(reg.outcome, 'handled');
assert.ok(api.inspect('chart-transform').participants.some(p => p.pluginId === PLUGIN_ID));
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(sel.payload.active, PROVIDER_ID);
assert.equal(sel.payload.installed, true);
assert.equal(highway.calls.set.length, 1);
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
assert.equal(typeof highway.calls.set[0].transform, 'function');
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
const names = events.map(e => e.event);
assert.ok(names.includes('provider-registered'));
assert.ok(names.includes('transform-changed'));
});
test('select-provider with an unknown id degrades', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const result = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: 'nope' },
});
assert.equal(result.outcome, 'degraded');
assert.match(result.reason, /Unknown chart-transform provider/);
});
test('selection without a highway is kept and installed on song:ready', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api);
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(sel.payload.installed, false, 'no highway yet');
const highway = makeFakeHighway();
window.highway = highway;
window.feedBack.emit('song:ready', {});
assert.equal(highway.calls.set.length, 1);
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
assert.equal(window.feedBack.chartTransformDomain.snapshot().installed, true);
});
test('a persisted selection restores when its provider registers', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway, persistedSelection: PROVIDER_ID });
const api = window.feedBack.capabilities;
await registerProvider(api);
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.active, PROVIDER_ID);
assert.equal(snapshot.activeSource, 'restore-selection');
assert.equal(highway.calls.set.length, 1);
});
test('unregister is registrant-only and detaches the active provider', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const denied = await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: 'someone_else', payload: { providerId: PROVIDER_ID },
});
assert.equal(denied.outcome, 'degraded');
assert.match(denied.reason, /original registrant/);
const ok = await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
});
assert.equal(ok.outcome, 'handled');
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.active, null);
assert.equal(snapshot.providers.length, 0);
// Detach = a trailing setChartTransform(null) on the highway.
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
// Persisted selection survives so re-registration re-activates.
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
});
test('unregister keeps a participant while another provider still references it', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api, { providerId: 'provider-a', label: 'Provider A' });
await registerProvider(api, { providerId: 'provider-b', label: 'Provider B' });
let participant = api.inspect('chart-transform').participants
.find(p => p.pluginId === PLUGIN_ID);
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a', 'provider-b']);
assert.deepEqual(
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
[{ id: 'provider-a', label: 'Provider A' }, { id: 'provider-b', label: 'Provider B' }],
);
await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: 'provider-b' },
});
participant = api.inspect('chart-transform').participants
.find(p => p.pluginId === PLUGIN_ID);
assert.ok(participant, 'the shared participant remains registered');
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a']);
assert.deepEqual(
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
[{ id: 'provider-a', label: 'Provider A' }],
);
assert.deepEqual(
Array.from(window.feedBack.chartTransformDomain.snapshot().providers, p => p.id),
['provider-a'],
);
await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: 'provider-a' },
});
assert.ok(!api.inspect('chart-transform').participants
.some(p => p.pluginId === PLUGIN_ID), 'the final removal unregisters the participant');
});
test('clear-provider clears the highway hook and the persisted selection', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const result = await api.dispatch({
capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui',
});
assert.equal(result.outcome, 'handled');
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
assert.equal(window.localStorage.getItem(STORAGE_KEY), null);
});
test('refresh re-runs the installed transform', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const result = await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
assert.equal(result.outcome, 'handled');
assert.equal(result.payload.refreshed, true);
assert.equal(highway.calls.refresh, 1);
});
test('announced highway instances (splitscreen panels) get the active transform', async () => {
const primary = makeFakeHighway();
const window = loadChartTransform({ highway: primary });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 1);
// A splitscreen panel announces its own createHighway() instance.
const panel = makeFakeHighway();
window.feedBack.emit('highway:created', { highway: panel });
assert.equal(panel.calls.set.length, 1, 'panel receives the active transform');
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 2);
// Refresh reaches every surface.
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
assert.equal(primary.calls.refresh, 1);
assert.equal(panel.calls.refresh, 1);
// Clearing detaches every surface.
await api.dispatch({ capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui' });
assert.equal(primary.calls.set[primary.calls.set.length - 1], null);
assert.equal(panel.calls.set[panel.calls.set.length - 1], null);
});
test('a panel announced before any selection installs on later select', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const panel = makeFakeHighway();
window.feedBack.emit('highway:created', { highway: panel });
await registerProvider(api);
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(panel.calls.set.length, 1);
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
});
test('highway failure events expose a fixed public reason', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
const events = captureEvents(api, ['chart-transform:transform-failed']);
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
window.feedBack.emit('highway:chart-transform-failed', {
id: PROVIDER_ID,
reason: 'token=secret https://example.test/private chart={notes:[...]}',
});
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.lastFailure.providerId, PROVIDER_ID);
assert.equal(snapshot.lastFailure.reason, 'Chart transform provider failed');
assert.equal(events.length, 1);
assert.equal(events[0].payload.reason, 'Chart transform provider failed');
});
test('diagnostics contribution carries the schema and no song identity fields', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api);
const contributions = window.feedBack.diagnostics.snapshotContributions();
const diag = contributions['chart-transform-capability'];
assert.ok(diag, 'diagnostics contributed');
assert.equal(diag.schema, 'feedBack.chart_transform.diagnostics.v1');
const flat = JSON.stringify(diag);
assert.ok(!/filename|title|artist|arrangement/.test(flat), 'no song identity in diagnostics');
});
+189
View File
@@ -0,0 +1,189 @@
// Verify `countInBeats(startT)` in static/js/count-in.js sizes the count-in
// to the song's own bar rather than a hardcoded four clicks.
//
// Two behaviours are under test:
// 1. Meter — a 3/4 song gets three clicks, not four.
// 2. Pickup (anacrusis) — a first bar shorter than the meter shortens the
// count so the pickup enters on its real beat (1-beat pickup in 4/4 →
// "1 2 3", music on 4). A full four there puts the pickup where the
// downbeat belongs and the player comes in a beat late all song.
//
// The meter is read from the song_timeline beats (`window.highway.getBeats()`,
// `measure >= 0` on downbeats) because that is the only meter data the
// frontend holds — the `time_signatures` map is streamed to plugins, not
// stored here.
//
// Same extraction approach as loop_restart.test.js: pull the function source
// out of the module and evaluate it in a vm sandbox with a stubbed highway,
// rather than loading the ESM module and its DOM-coupled imports.
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 COUNT_IN_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
// Brace-match the function body out of the source. Brittle by design:
// a rename fails loudly here rather than silently skipping coverage.
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found`);
const openBrace = src.indexOf('{', start + signature.length);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
const src = fs.readFileSync(COUNT_IN_JS, 'utf8');
// Drop the `export` keyword so the body evaluates as a plain declaration.
const fnSrc = extractFunction(src, 'export function countInBeats')
.replace(/^export\s+/, '');
// `beats` is the song_timeline shape: {time, measure}, measure >= 0 only on
// downbeats. `getBeats` may also be absent entirely (pre-chart / minigame).
function load(beats) {
const sandbox = {
window: beats === undefined
? { highway: {} }
: { highway: { getBeats: () => beats } },
};
vm.createContext(sandbox);
vm.runInContext(`${fnSrc}; globalThis.__fn = countInBeats;`, sandbox);
return sandbox.__fn;
}
// Build a beats array: `bars` full bars of `beatsPerBar`, optionally preceded
// by a pickup of `pickup` beats. One beat per 0.5 s throughout.
function makeBeats({ beatsPerBar = 4, bars = 4, pickup = 0 } = {}) {
const out = [];
let t = 0;
let measure = 0;
if (pickup > 0) {
for (let i = 0; i < pickup; i++) {
out.push({ time: t, measure: i === 0 ? measure : -1 });
t += 0.5;
}
measure++;
}
for (let b = 0; b < bars; b++) {
for (let i = 0; i < beatsPerBar; i++) {
out.push({ time: t, measure: i === 0 ? measure : -1 });
t += 0.5;
}
measure++;
}
return out;
}
// ── Meter ────────────────────────────────────────────────────────────────
test('countInBeats counts a full bar in 4/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4 }));
assert.equal(countInBeats(0), 4);
});
test('countInBeats counts three in 3/4 (was hardcoded four)', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 3 }));
assert.equal(countInBeats(0), 3);
});
test('countInBeats counts six in 6/8', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 6 }));
assert.equal(countInBeats(0), 6);
});
// ── Pickup (anacrusis) ───────────────────────────────────────────────────
test('countInBeats shortens the count by a 1-beat pickup in 4/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 }));
assert.equal(countInBeats(0), 3, 'counts 1-2-3 so the pickup lands on 4');
});
test('countInBeats shortens the count by a 2-beat pickup in 4/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 2 }));
assert.equal(countInBeats(0), 2);
});
test('countInBeats handles a pickup in 3/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 3, pickup: 1 }));
assert.equal(countInBeats(0), 2);
});
test('countInBeats finds the meter when the song is only a pickup plus one bar', () => {
// Gap counts tie (one 1-beat gap, one 4-beat gap) — the longer bar is the
// meter, so this must be 3 rather than 0.
const countInBeats = load(makeBeats({ beatsPerBar: 4, bars: 1, pickup: 1 }));
assert.equal(countInBeats(0), 3);
});
// ── Resuming somewhere other than the song top ───────────────────────────
test('countInBeats counts a full bar at a mid-song downbeat, pickup notwithstanding', () => {
const beats = makeBeats({ beatsPerBar: 4, pickup: 1 });
const countInBeats = load(beats);
// Index 5 is the downbeat of the second full bar (1 pickup + 4 beats).
assert.equal(beats[5].measure >= 0, true, 'fixture sanity: index 5 is a downbeat');
assert.equal(countInBeats(beats[5].time), 4);
});
test('countInBeats counts a mid-song meter change by that bar, not as a pickup', () => {
// 4/4 throughout, except one 3-beat bar at index 8. Treating a short bar
// anywhere but the song's first as a pickup would count a single click.
const beats = [];
let t = 0;
const push = (n, measure) => {
for (let i = 0; i < n; i++) { beats.push({ time: t, measure: i === 0 ? measure : -1 }); t += 0.5; }
};
push(4, 0); push(4, 1); push(3, 2); push(4, 3); push(4, 4);
const countInBeats = load(beats);
assert.equal(beats[8].measure, 2, 'fixture sanity: index 8 opens the 3-beat bar');
assert.equal(countInBeats(beats[8].time), 3, 'counts the short bar itself');
assert.equal(countInBeats(0), 4, 'the 4/4 opening is unaffected');
});
test('countInBeats counts a full bar when resuming mid-bar', () => {
const beats = makeBeats({ beatsPerBar: 4 });
const countInBeats = load(beats);
assert.equal(countInBeats(beats[2].time), 4); // third beat of bar 1
});
test('countInBeats tolerates a start time slightly past the beat (seek slop)', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 }));
assert.equal(countInBeats(0.02), 3);
});
// ── Fallbacks ────────────────────────────────────────────────────────────
test('countInBeats falls back to four without a beats array', () => {
assert.equal(load(undefined)(0), 4, 'no getBeats (pre-chart / minigame)');
assert.equal(load([])(0), 4, 'empty beats');
assert.equal(load(null)(0), 4, 'null beats');
});
test('countInBeats falls back to four when beats carry no downbeat labels', () => {
const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time, measure: -1 }));
assert.equal(load(beats)(0), 4);
});
test('countInBeats falls back to four with only one downbeat', () => {
const beats = [
{ time: 0, measure: 0 },
{ time: 0.5, measure: -1 },
{ time: 1.0, measure: -1 },
];
assert.equal(load(beats)(0), 4);
});
test('countInBeats counts a full bar past the last beat', () => {
const beats = makeBeats({ beatsPerBar: 3 });
assert.equal(load(beats)(9999), 3);
});
+182
View File
@@ -0,0 +1,182 @@
// Behavioral tests for static/v3/gamepad.js — the controller polling state
// machine. gamepad.js is a plain IIFE with no exports, so it's loaded into a vm
// with a fake navigator/window/document and driven frame-by-frame through a
// manual requestAnimationFrame queue. This exercises the parts that were only
// ever checked on a real Steam Deck: standard-mapping filtering, Steam Input's
// duplicate-slot dedup, disconnect masking, button edge-detection, d-pad/stick
// key-repeat timing, and the analog-stick deadzone.
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 SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad.js'), 'utf8');
function pad(index, opts = {}) {
return {
index,
connected: opts.connected !== false,
mapping: opts.mapping || 'standard',
buttons: (opts.buttons || []).map(p => ({ pressed: !!p })),
axes: opts.axes || [0, 0],
};
}
// Load a fresh gamepad.js instance with a controllable environment.
function load() {
let pads = [];
const listeners = {};
const rafQueue = [];
const fired = []; // synthetic key codes dispatched at the focused element
const toasts = []; // {title,...} from fbNotify.show
let clock = 0;
const activeElement = { dispatchEvent(evt) { fired.push(evt.code); return true; } };
const sandbox = {
console: { log() {}, error() {} },
performance: { now: () => clock },
requestAnimationFrame: (fn) => { rafQueue.push(fn); return rafQueue.length; },
navigator: { getGamepads: () => pads },
KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } },
document: {
activeElement,
// revealPlayerRail() looks these up; returning null makes button 3 a no-op.
querySelector: () => null,
},
window: {
addEventListener: (t, fn) => { (listeners[t] || (listeners[t] = [])).push(fn); },
fbNotify: { show: (o) => toasts.push(o) },
},
};
vm.runInNewContext(SRC, sandbox);
const emit = (type, gamepad) => (listeners[type] || []).forEach(fn => fn({ gamepad }));
return {
setPads: (arr) => { pads = arr; },
connect: (gp) => emit('gamepadconnected', gp),
disconnect: (gp) => emit('gamepaddisconnected', gp),
tick: () => { const fn = rafQueue.shift(); if (fn) fn(); },
polling: () => rafQueue.length > 0, // a live tick re-queues itself only while polling
setClock: (t) => { clock = t; },
fired, toasts,
};
}
test('a non-standard pad is ignored entirely (no toast, no polling)', () => {
const g = load();
const p = pad(0, { mapping: 'xbox-nonstandard' });
g.setPads([p]);
g.connect(p);
assert.equal(g.toasts.length, 0);
assert.equal(g.polling(), false);
});
test('a standard pad connecting toasts once and starts polling', () => {
const g = load();
const p = pad(0);
g.setPads([p]);
g.connect(p);
assert.equal(g.toasts.length, 1);
assert.equal(g.toasts[0].title, 'Controller connected');
assert.equal(g.polling(), true);
});
test("Steam Input's duplicate virtual slots only toast once", () => {
const g = load();
const a = pad(0), b = pad(1);
g.setPads([a, b]);
g.connect(a);
g.connect(b); // same physical controller, second XInput mirror slot
assert.equal(g.toasts.length, 1, 'one physical controller = one toast');
});
test('face buttons edge-detect: fire once per press, not once per frame', () => {
const g = load();
const p = pad(0, { buttons: [true] }); // button 0 held down
g.setPads([p]);
g.connect(p);
g.tick();
g.tick(); // still held on the next frame
assert.deepEqual(g.fired, ['Space'], 'held button must not auto-repeat');
p.buttons[0].pressed = false; g.tick(); // release
p.buttons[0].pressed = true; g.tick(); // press again
assert.deepEqual(g.fired, ['Space', 'Space'], 'a fresh press fires again');
});
test('button 1 maps to Escape; button 3 (rail reveal) fires no key', () => {
const g = load();
const p = pad(0, { buttons: [false, true, false, true] });
g.setPads([p]);
g.connect(p);
g.tick();
assert.deepEqual(g.fired, ['Escape'], 'B=Escape, Y=rail-reveal (no synthetic key)');
});
test('d-pad / stick repeat: initial fire, delay, then interval repeats', () => {
const g = load();
const p = pad(0, { buttons: [] }); // no buttons; drive via the d-pad indices
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
p.buttons[13].pressed = true; // ArrowDown
g.setPads([p]);
g.connect(p);
g.setClock(0); g.tick(); // initial press
g.setClock(399); g.tick(); // before the 400ms repeat delay
g.setClock(400); g.tick(); // repeat delay elapsed
assert.deepEqual(g.fired, ['ArrowDown', 'ArrowDown'], 'one initial + one repeat at 400ms, nothing at 399ms');
});
test('analog stick honors the deadzone', () => {
const g = load();
const p = pad(0);
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
g.setPads([p]);
g.connect(p);
p.axes = [0, 0.4]; g.setClock(0); g.tick(); // below 0.5 deadzone → nothing
assert.deepEqual(g.fired, [], 'sub-deadzone deflection is ignored');
p.axes = [0.6, 0]; g.setClock(1); g.tick(); // right, past deadzone
assert.deepEqual(g.fired, ['ArrowRight']);
});
test('disconnecting one of two live slots does not stop polling or toast', () => {
const g = load();
const a = pad(0), b = pad(1);
g.setPads([a, b]);
g.connect(a); g.connect(b);
g.toasts.length = 0;
b.connected = false; // Steam mirror slot drops
g.setPads([a, b]);
g.disconnect(b);
assert.equal(g.toasts.length, 0, 'a still-live standard pad masks the mirror disconnect');
assert.equal(g.polling(), true);
});
test('disconnecting the last live pad stops polling and toasts', () => {
const g = load();
const a = pad(0);
g.setPads([a]);
g.connect(a);
a.connected = false;
g.setPads([a]);
g.disconnect(a);
assert.equal(g.toasts.some(t => t.title === 'Controller disconnected'), true);
// Drain the final queued tick; polling must not re-queue itself.
g.tick();
assert.equal(g.polling(), false);
});
test('polling acts only on the live standard pad, skipping stale/non-standard slots', () => {
const g = load();
const dead = pad(0, { connected: false, buttons: [true] }); // frozen, disconnected
const raw = pad(1, { mapping: 'raw-hid', buttons: [true] }); // non-standard
const live = pad(2, { buttons: [true] }); // standard, button 0 down
g.setPads([dead, raw, live]);
g.connect(live);
g.tick();
assert.deepEqual(g.fired, ['Space'], 'input read from the live standard pad only');
});
+157
View File
@@ -0,0 +1,157 @@
// Behavioral tests for static/v3/gamepad-nav.js — the generic Tab-order
// emulation layer. Loaded into a vm with a minimal fake DOM; the module's
// single keydown listener is captured and fed synthetic events. Covers the
// three things it does: arrow-key focus traversal (with clamping), Enter/Space
// activation via .click() (Chromium won't natively activate untrusted keys),
// and the Escape "go back" fallback — plus the !isTrusted / defaultPrevented
// gating that keeps it off real keyboard users.
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 SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad-nav.js'), 'utf8');
function load() {
const state = { focused: null, clicked: [], screens: [] };
const body = { tagName: 'BODY' };
const cfg = { modal: null, nav: null, screen: null, backButtons: [], activeEl: body };
let handler = null;
function elem(opts = {}) {
return {
tagName: opts.tagName || 'BUTTON',
type: opts.type,
isContentEditable: !!opts.isContentEditable,
offsetParent: opts.visible === false ? null : {},
_focusables: opts.focusables || [],
querySelectorAll() { return this._focusables; },
focus() { state.focused = this; },
click() { state.clicked.push(this); },
};
}
const document = {
body,
get activeElement() { return cfg.activeEl; },
addEventListener(type, fn) { if (type === 'keydown') handler = fn; },
querySelector(sel) {
if (sel.includes('dialog') || sel.includes('modal')) return cfg.modal;
if (sel.includes('screen.active')) return cfg.screen;
return null;
},
getElementById(id) { return id === 'v3-nav' ? cfg.nav : null; },
querySelectorAll() { return cfg.backButtons; }, // only the Escape back-button lookup uses this
};
const sandbox = { document, window: { showScreen: (id) => state.screens.push(id) } };
vm.runInNewContext(SRC, sandbox);
const fire = (over) => handler(Object.assign({ isTrusted: false, defaultPrevented: false, key: '' }, over));
return { cfg, state, body, elem, fire };
}
// Build a screen holding `n` visible focusables; expose them for cfg.activeEl.
function screenWith(g, n) {
const items = Array.from({ length: n }, () => g.elem());
g.cfg.screen = g.elem({ focusables: items });
g.cfg.nav = g.elem({ focusables: [] });
return items;
}
test('real keyboard input (isTrusted) is never touched', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[0];
g.fire({ isTrusted: true, key: 'ArrowDown' });
assert.equal(g.state.focused, null, 'trusted events must pass through untouched');
});
test('a key already handled by another listener (defaultPrevented) is skipped', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[0];
g.fire({ defaultPrevented: true, key: 'ArrowDown' });
assert.equal(g.state.focused, null);
});
test('ArrowDown/Right moves to the next focusable; ArrowUp/Left to the previous', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[1];
g.fire({ key: 'ArrowDown' });
assert.equal(g.state.focused, items[2], 'Down = next');
g.cfg.activeEl = items[1];
g.fire({ key: 'ArrowLeft' });
assert.equal(g.state.focused, items[0], 'Left = previous');
});
test('traversal clamps at both ends', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[2];
g.fire({ key: 'ArrowDown' });
assert.equal(g.state.focused, items[2], 'no wrap past the last item');
g.cfg.activeEl = items[0];
g.fire({ key: 'ArrowUp' });
assert.equal(g.state.focused, items[0], 'no wrap before the first item');
});
test('with nothing relevant focused, the first arrow lands on the first item', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = g.body; // not in the focusable list
g.fire({ key: 'ArrowRight' });
assert.equal(g.state.focused, items[0]);
});
test('hidden focusables are skipped (offsetParent visibility)', () => {
const g = load();
const visibleA = g.elem();
const hidden = g.elem({ visible: false });
const visibleB = g.elem();
g.cfg.screen = g.elem({ focusables: [visibleA, hidden, visibleB] });
g.cfg.nav = g.elem({ focusables: [] });
g.cfg.activeEl = visibleA;
g.fire({ key: 'ArrowDown' });
assert.equal(g.state.focused, visibleB, 'the hidden element is not a traversal stop');
});
test('Enter/Space activates the focused control via click()', () => {
const g = load();
const btn = g.elem({ tagName: 'BUTTON' });
g.cfg.activeEl = btn;
g.fire({ key: 'Enter' });
g.fire({ key: ' ' });
assert.deepEqual(g.state.clicked, [btn, btn], 'both Enter and Space activate');
});
test('activation never clicks a focused text field or the body', () => {
const g = load();
g.cfg.activeEl = g.elem({ tagName: 'INPUT', type: 'text' });
g.fire({ key: 'Enter' });
g.cfg.activeEl = g.body;
g.fire({ key: ' ' });
assert.deepEqual(g.state.clicked, [], 'no synthetic click into a text input or the bare body');
});
test('Escape clicks the visible in-screen back button when one exists', () => {
const g = load();
const hiddenBack = g.elem({ visible: false }); // a back button from another, now-hidden screen
const visibleBack = g.elem();
g.cfg.backButtons = [hiddenBack, visibleBack];
g.fire({ key: 'Escape' });
assert.deepEqual(g.state.clicked, [visibleBack], 'the visible back button wins, not DOM order');
assert.deepEqual(g.state.screens, [], 'no home fallback while a back button handled it');
});
test('Escape with no visible back button falls back to the home screen', () => {
const g = load();
g.cfg.backButtons = [g.elem({ visible: false })];
g.fire({ key: 'Escape' });
assert.deepEqual(g.state.screens, ['v3-home']);
assert.deepEqual(g.state.clicked, []);
});
+98
View File
@@ -0,0 +1,98 @@
// A gig is a SET, not a run of unrelated songs.
//
// Reported from a live gig: the player finished the first song and had to sit
// through the per-song results popup before the next one would start, and then
// wait again while that song was extracted from its feedpak zip.
//
// This file covers the CORE half — career pre-extracts the whole setlist before
// the first note. The other half (note_detect must not show its per-song summary
// inside a gig) lives in the note_detect plugin repo, which is not part of this
// checkout: plugins/*/ is gitignored here and note_detect ships from
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
// box (where the plugin happens to be bundled) and fail in CI, which is worse
// than no test.
//
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('startGig extracts the whole setlist before starting the queue', () => {
const fn = extractBlock(CAREER, 'async function startGig(');
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
const startIdx = fn.search(/q\.start\s*\(/);
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
assert.ok(startIdx !== -1, 'q.start not found');
assert.ok(prepIdx < startIdx,
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
'waits between songs, which is the bug');
});
test('the stage is only borrowed once the set is ready', () => {
const fn = extractBlock(CAREER, 'async function startGig(');
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
assert.ok(prepIdx < stageIdx,
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
'a proposal dismissed while unpacking must not then start a gig');
});
test('pre-extraction never blocks the gig from starting', () => {
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
assert.match(fn, /catch\s*\(/,
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
});
test('the prepare route degrades instead of failing', () => {
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
'is an optimisation and can never be why a gig will not start');
});
// ── the prepare must never be able to BLOCK the gig (CodeRabbit, #971) ──────
//
// A bare `await fetch(...)` only rejects on a network error. A server that
// accepts the connection and then never answers hangs forever — and the gig
// would never start. That would make this optimisation the exact thing it
// promises never to be: the reason you cannot play.
test('the prepare fetch is bounded — a hung server cannot block the gig', () => {
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
assert.match(fn, /AbortController/, 'the request must be abortable');
assert.match(fn, /setTimeout\([\s\S]{0,40}abort\s*\(\s*\)/,
'a hung request must be aborted, not awaited forever');
assert.match(fn, /signal:\s*ctrl\.signal/, 'the signal must actually be passed to fetch');
assert.match(fn, /clearTimeout/, 'the timer must be cleared on the happy path');
assert.match(CAREER, /const\s+PREPARE_TIMEOUT_MS\s*=\s*\d+/, 'the ceiling must be named');
// The button must be restored however we leave — otherwise a timeout strands
// the poster on "Preparing set…" with Play disabled: unplayable.
assert.match(fn, /finally\s*\{[\s\S]{0,220}btn\.disabled\s*=\s*false/,
'the Play button must be re-enabled on EVERY path, including the abort');
});
+346
View File
@@ -0,0 +1,346 @@
// h3d-carve-12: Regression coverage for T-section (arpeggio inference) extracted
// into plugins/highway_3d/src/arp.js.
//
// Test classes:
// - Source-level: module shape, DI param presence, screen.js wiring
// - Wiring-correspondence guard (naming-class invariant, PINNED_RENAMES = {})
// - Amendment 2 behavioral kill: resetChordShapeCache identity (gut reset → RED)
// - Amendment 3 behavioral kill: WeakMap re-keying guard (gut ref-keying → RED)
// - Per-export behavioral kills: mergeHandShapeSynthChords, mergeChordShape,
// chordShapeCoveredByStandaloneNotes, chordWireHighDensity, chordTemplateLabel
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const ARP_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'arp.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const arpSrc = fs.readFileSync(ARP_JS, 'utf8');
// ── Module shape ─────────────────────────────────────────────────────────────
test('arp.js exports createArp', () => {
assert.match(arpSrc, /export\s+function\s+createArp\s*\(/,
'arp.js must export createArp');
});
const EXPECTED_EXPORTS = [
'chordWireHighDensity', 'chordTemplateLabel', 'chordTemplateMarkedArpeggio',
'chordHandShapeArpeggioHint', 'mergeHandShapeSynthChords', 'mergeChordShape',
'resetChordShapeCache', 'inferArpeggioFromNotePattern',
'chordShapeCoveredByStandaloneNotes', 'hsStart', 'hsEnd', 'handShapeChartSpanSec',
'fillArpeggioGhostInferFlags', 'arpeggioChordIdForNoteWithInferCache',
'arpHsBoundsForNote', 'fillLaneRailHandShapeFlags', 'fillArpeggioRailShapeBoundsCaches',
'arpeggioLaneOuterRailLaneSlice', 'arpeggioLaneOuterRailAtChartTime',
'arpeggioLaneDividerFrameAccentMul', 'arpeggioLaneDividerXYScaleMatchFrameRim',
];
test('createArp return object declares all 21 exported symbols', () => {
for (const sym of EXPECTED_EXPORTS) {
assert.match(arpSrc, new RegExp('\\b' + sym + '\\b'),
`arp.js must mention '${sym}'`);
}
// The factory return is the last `return {` in the file (inner returns are earlier)
const lastReturnIdx = arpSrc.lastIndexOf('return {');
assert.ok(lastReturnIdx >= 0, 'createArp must have a return { ... } block');
const returnBlock = arpSrc.slice(lastReturnIdx);
const returnMatch = returnBlock.match(/return\s*\{([^}]+)\}/s);
assert.ok(returnMatch, 'factory return block must be parseable');
for (const sym of EXPECTED_EXPORTS) {
assert.ok(
returnMatch[1].includes(sym),
`return block must include '${sym}'`,
);
}
});
test('arp.js imports lowerBoundT directly from geometry.js (not via DI)', () => {
assert.match(arpSrc,
/import\s*\{\s*lowerBoundT\s*\}\s*from\s*'\.\/geometry\.js'/,
'lowerBoundT must be imported from geometry.js');
assert.doesNotMatch(arpSrc, /lowerBoundT\s*,/,
'lowerBoundT must not appear in the DI parameter list');
});
// ── DI surface checks ────────────────────────────────────────────────────────
test('NEXT_ON_STRING_T_EPS is in the DI parameter list (late-found in survey)', () => {
// Must appear as a destructured parameter, not just in usage
const paramBlock = arpSrc.match(/export\s+function\s+createArp\s*\(\s*\{([^}]+)\}/s);
assert.ok(paramBlock, 'must find createArp parameter block');
assert.ok(
paramBlock[1].includes('NEXT_ON_STRING_T_EPS'),
'NEXT_ON_STRING_T_EPS must be listed as a DI parameter',
);
});
test('getNStr getter is used in arpeggioLaneDividerXYScaleMatchFrameRim body (DI rewire)', () => {
assert.match(arpSrc, /getNStr\(\)/,
'getNStr() must be called somewhere in arp.js');
assert.match(arpSrc,
/arpeggioLaneDividerXYScaleMatchFrameRim[\s\S]{1,400}getNStr\(\)/,
'getNStr() must appear inside arpeggioLaneDividerXYScaleMatchFrameRim body');
});
// ── screen.js wiring ─────────────────────────────────────────────────────────
test('screen.js imports createArp from src/arp.js', () => {
assert.match(src,
/import\s*\{\s*createArp\s*\}\s*from\s*'\.\/src\/arp\.js'/,
'screen.js must import createArp');
});
test('screen.js T-section body is gone (truthyChartFlag function removed)', () => {
// truthyChartFlag lived only in the T-section and is private (not exported)
assert.doesNotMatch(src, /function\s+truthyChartFlag\s*\(/,
'truthyChartFlag must not remain as a function declaration in screen.js');
});
test('screen.js no longer contains _chordShapeCache = new WeakMap() direct assignment', () => {
// After cut, _chordShapeCache lives in arp.js; screen.js only calls resetChordShapeCache()
assert.doesNotMatch(src, /_chordShapeCache\s*=\s*new\s+WeakMap\(\)/,
'_chordShapeCache direct assignment must be gone from screen.js');
});
test('screen.js _resetStringDependentCaches calls resetChordShapeCache()', () => {
assert.match(src, /resetChordShapeCache\(\)/,
'screen.js must call resetChordShapeCache() in _resetStringDependentCaches');
});
test('screen.js callsite uses createArp factory destructure', () => {
assert.match(src,
/const\s*\{[\s\S]*chordWireHighDensity[\s\S]*\}\s*=\s*createArp\s*\(/,
'screen.js must destructure from createArp()');
});
// ── Wiring-correspondence guard ───────────────────────────────────────────────
// Every entry in createArp({…}) must satisfy its naming class.
// PINNED_RENAMES = {} (all entries follow standard convention).
// Kills param swaps like `getNStr: () => nStr` → `getNStr: () => mStr`.
test('createArp({...}) wiring has correct naming correspondence (no param swaps)', () => {
const PINNED_RENAMES = {};
const ANCHOR = '} = createArp({';
const callStart = src.indexOf(ANCHOR);
assert.ok(callStart >= 0, 'createArp call must be findable in screen.js');
const blockStart = callStart + ANCHOR.length - 1;
assert.equal(src[blockStart], '{', 'expected { at computed blockStart');
let depth = 0, blockEnd = -1;
for (let i = blockStart; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) { blockEnd = i; break; }
}
assert.ok(blockEnd > blockStart, 'createArp argument block must have balanced braces');
const inner = src.slice(blockStart + 1, blockEnd);
const rawEntries = [];
let current = '', d = 0;
for (let i = 0; i < inner.length; i++) {
const ch = inner[i];
if (ch === '{') d++;
else if (ch === '}') d--;
if (ch === ',' && d === 0) {
const t = current.trim();
if (t) rawEntries.push(t);
current = '';
} else {
current += ch;
}
}
if (current.trim()) rawEntries.push(current.trim());
const entries = rawEntries
.map(e => e.replace(/\/\/[^\n]*/g, '').trim())
.filter(Boolean);
assert.ok(entries.length >= 19, `expected at least 19 entries, got ${entries.length}`);
const violations = [];
for (const entry of entries) {
if (!entry.includes(':')) continue; // shorthand (UPPERCASE or camelCase plain ref)
const colonIdx = entry.indexOf(':');
const key = entry.slice(0, colonIdx).trim();
const value = entry.slice(colonIdx + 1).trim();
if (key in PINNED_RENAMES) {
if (value !== PINNED_RENAMES[key])
violations.push(`${key}: pinned to '${PINNED_RENAMES[key]}' but got '${value}'`);
continue;
}
if (key.startsWith('get')) {
const expectedStem = key[3].toLowerCase() + key.slice(4);
const m = value.match(/^\(\)\s*=>\s*_?(\w+)$/);
if (!m) {
violations.push(`${key}: getter value '${value}' does not match () => [_]var`);
continue;
}
if (m[1] !== expectedStem) {
violations.push(`${key}: getter body references var stem '${m[1]}' but expected '${expectedStem}'`);
}
continue;
}
violations.push(`${key}: key:value entry not in PINNED_RENAMES and not a get-arrow`);
}
assert.deepEqual(violations, [], 'createArp wiring violations found');
});
// ── Behavioral fixture ────────────────────────────────────────────────────────
// Provides a default createArp instance with all 19 DI params stubbed.
async function makeArp(overrides = {}) {
const { createArp } = await import(pathToFileURL(ARP_JS).href + '?t=' + Date.now());
const defaults = {
validString: (s) => s >= 0 && s < 6,
filterValidNotes: (notes) => notes.filter(n => n.s >= 0 && n.s < 6),
sY: (s) => s * 10,
K: 5.0,
S_GAP: 10,
BEHIND: 100,
CHORD_FRAME_RIM_MIN: 0.01,
CHORD_FRAME_RIM_FRAC_H: 0.1,
ARP_FRAME_ONSET_PAD_S: 0.01,
ARP_FRAME_ONSET_CLUSTER_S: 0.03,
ARP_INFER_MIN_HAND_SHAPE_SPAN_S: 0.1,
ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S: 0.03,
ARP_INFER_MULTI_STRUM_HIT_SLACK: 0.02,
ARP_INFER_MULTI_STRUM_WIN_MIN_S: 0.1,
ARP_INFER_MIN_HITS_VS_SHAPE_CAP: 0.5,
ARP_HWY_RAIL_END_TAIL_S: 0.2,
ARP_HWY_RAIL_START_LEAD_S: 0.1,
NEXT_ON_STRING_T_EPS: 0.001,
getNStr: () => 6,
...overrides,
};
return createArp(defaults);
}
// ── Amendment 2: resetChordShapeCache identity-based kill ─────────────────────
// r1 = mergeChordShape(ch,...); r2 = same call → assert r1 === r2 (cache hit).
// resetChordShapeCache(); r3 = same call → assert r3 !== r1 (recomputed object).
// Gut the reset (no-op instead of new WeakMap) → r3 === r1 → RED.
test('Amendment 2: resetChordShapeCache invalidates the WeakMap — identity kill', async () => {
const arp = await makeArp();
const ch = { id: 0, t: 0 };
const notes = [{ s: 0, f: 3 }];
const templates = {};
const r1 = arp.mergeChordShape(ch, notes, templates);
const r2 = arp.mergeChordShape(ch, notes, templates);
assert.ok(r1 === r2, 'second call with same chord ref must return the cached Map (identity hit)');
arp.resetChordShapeCache();
const r3 = arp.mergeChordShape(ch, notes, templates);
assert.ok(r3 !== r1,
'call after resetChordShapeCache() must return a NEW Map object — gut the reset → r3 === r1 → RED');
// Sanity: content must still be the same even though the object changed
assert.deepEqual([...r3.entries()], [...r1.entries()], 'reset must not change computed shape data');
});
// ── Amendment 3: WeakMap re-keying guard ──────────────────────────────────────
// Simulates a song-switch: old chord objects dropped (new refs arrive).
// Same-refs: r1 === r2 (cache hit by object identity).
// New-refs: r3 !== r1 (WeakMap miss → recompute — not stale).
// Gut the ref-compare (switch to string-keyed Map by ch.id) → r3 === r1 → RED
// when ch2 has the same id as ch1.
test('Amendment 3: WeakMap re-keying — same-ref hit, new-ref recompute (song-switch guard)', async () => {
const arp = await makeArp();
const ch1 = { id: 7, t: 1.0 };
const ch2 = { id: 7, t: 1.0 }; // same data, different object reference
const notes = [];
const templates = { 7: { frets: [0, 1, 2, -1, -1, -1] } };
const r1 = arp.mergeChordShape(ch1, notes, templates);
const r2 = arp.mergeChordShape(ch1, notes, templates);
assert.ok(r1 === r2, 'same chord ref must get a cache hit (r1 === r2)');
const r3 = arp.mergeChordShape(ch2, notes, templates);
assert.ok(r3 !== r1,
'different chord ref (song-switch) must NOT get the stale cached entry — gut ref-keying → r3 === r1 → RED');
// Content must still be equal (same inputs)
assert.deepEqual([...r3.entries()], [...r1.entries()], 'recomputed shape must equal original');
});
// ── mergeChordShape behavioral kill ──────────────────────────────────────────
// Chord note override must win over template fret for the same string.
test('mergeChordShape: chord note overrides template fret on same string', async () => {
const arp = await makeArp();
const ch = { id: 5, t: 2.0 };
const notes = [{ s: 0, f: 7 }]; // override string 0 fret to 7
const templates = { 5: { frets: [3, 5, -1, -1, -1, -1] } }; // template says s0=3, s1=5
const shape = arp.mergeChordShape(ch, notes, templates);
assert.equal(shape.get(0), 7, 'chord note fret must override template fret on string 0');
assert.equal(shape.get(1), 5, 'template fret for string 1 must be preserved');
});
// ── mergeHandShapeSynthChords behavioral kill ─────────────────────────────────
// A hand shape with no coincident real chord must produce a synth chord entry.
test('mergeHandShapeSynthChords: synthesizes chord when hand-shape has no matching real chord', async () => {
const arp = await makeArp();
const templates = { 3: { frets: [0, 2, 2, -1, -1, -1] } };
const realChords = [];
const handShapes = [{ chord_id: 3, start_time: 1.0, end_time: 2.0 }];
const merged = arp.mergeHandShapeSynthChords(realChords, handShapes, templates);
assert.ok(merged.length === 1, 'one synth chord must be produced from the hand shape');
assert.ok(merged[0].h3dSynth === true, 'synth chord must be flagged h3dSynth');
assert.equal(merged[0].id, 3, 'synth chord must carry the hand-shape chord_id');
assert.ok(merged[0].notes.length > 0, 'synth chord must have notes from template');
});
test('mergeHandShapeSynthChords: real chord at same onset suppresses synth (no duplicate)', async () => {
const arp = await makeArp();
const templates = { 3: { frets: [0, 2, 2, -1, -1, -1] } };
const realChords = [{ t: 1.0, id: 3, notes: [] }];
const handShapes = [{ chord_id: 3, start_time: 1.0, end_time: 2.0 }];
const merged = arp.mergeHandShapeSynthChords(realChords, handShapes, templates);
assert.equal(merged.length, 1, 'real chord at same onset must suppress synth — no duplicate');
assert.ok(!merged[0].h3dSynth, 'the surviving entry must be the real chord, not synth');
});
// ── chordWireHighDensity / chordTemplateLabel simple kills ────────────────────
test('chordWireHighDensity returns true when chord.hd is truthy (boolean, 1, or "1")', async () => {
const arp = await makeArp();
assert.ok(arp.chordWireHighDensity({ hd: true }));
assert.ok(arp.chordWireHighDensity({ hd: 1 }));
assert.ok(arp.chordWireHighDensity({ hd: '1' }));
assert.ok(!arp.chordWireHighDensity({ hd: false }));
assert.ok(!arp.chordWireHighDensity({ hd: 0 }));
});
test('chordTemplateLabel returns displayName over name, empty string for null', async () => {
const arp = await makeArp();
assert.equal(arp.chordTemplateLabel({ displayName: 'Gm', name: 'Gm7' }), 'Gm');
assert.equal(arp.chordTemplateLabel({ name: 'Am' }), 'Am');
assert.equal(arp.chordTemplateLabel(null), '');
assert.equal(arp.chordTemplateLabel({}), '');
});
// ── arpeggioLaneDividerXYScaleMatchFrameRim DI rewire check ──────────────────
// getNStr() must be called to look up nStr; if it were hardcoded to a constant
// the test would break when getNStr returns a different value.
test('arpeggioLaneDividerXYScaleMatchFrameRim uses getNStr() for string count (DI rewire)', async () => {
const calls = [];
const arp = await makeArp({ getNStr: () => { calls.push(true); return 4; } });
// Call the function (it uses sY(0) and sY(getNStr()-1), both derived from nStr)
arp.arpeggioLaneDividerXYScaleMatchFrameRim(1.0);
assert.ok(calls.length > 0, 'getNStr() must be called inside arpeggioLaneDividerXYScaleMatchFrameRim');
});
+9 -5
View File
@@ -11,13 +11,17 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-12: chordShapeCoveredByStandaloneNotes moved to src/arp.js
const ARP_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'arp.js');
// h3d-carve-15: deferChordGems / noteStreamCoversArpShape moved to src/renderer.js
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
test('chordShapeCoveredByStandaloneNotes helper exists with the expected signature', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(ARP_JS, 'utf8');
assert.match(
src,
/function\s+chordShapeCoveredByStandaloneNotes\s*\(\s*ch\s*,\s*shape\s*,\s*notesArr\s*,\s*timeWin\s*\)/,
'helper that scans the note stream for shape coverage must remain on screen.js',
'helper that scans the note stream for shape coverage must be in src/arp.js (moved from screen.js by h3d-carve-12)',
);
});
@@ -25,7 +29,7 @@ test('deferChordGems gates both synth and explicit+covered branches on note-stre
// Either branch firing without coverage produces the empty-lavender-frame
// regression PR #262 fixed. Pin both predicates so a refactor that drops
// one gate fails the test.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
assert.match(
src,
/const\s+deferChordGems\s*=\s*\(\s*ch\.h3dSynth\s*&&\s*noteStreamCoversArpShape\(\)\s*\)\s*\|\|\s*inferredArpPattern\s*\|\|\s*\(\s*hsHintFrame\.explicit\s*&&\s*hsHintFrame\.covered\s*&&\s*noteStreamCoversArpShape\(\)\s*\)/,
@@ -37,14 +41,14 @@ test('noteStreamCoversArpShape is computed lazily (called, not eagerly bound)',
// Eager allocation regressed perf on dense charts (Copilot review on PR
// #262). The shape must be a callable so short-circuit evaluation skips
// the note-stream scan when neither gating branch needs it.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
assert.match(
src,
/const\s+noteStreamCoversArpShape\s*=\s*(?:\(\s*\)\s*=>|function(?:\s+\w+)?\s*\(\s*\))/,
'noteStreamCoversArpShape must be an arrow/function so the scan is lazy',
);
assert.doesNotMatch(
src,
fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'),
/const\s+noteStreamCoversArpShape\s*=\s*chordShapeCoveredByStandaloneNotes\(/,
'noteStreamCoversArpShape must not eagerly invoke the coverage helper',
);
+239
View File
@@ -0,0 +1,239 @@
// Class-killer tests for src/bc-panel.js — h3d-carve-4.
//
// Most tests are source-scan (grep for structural invariants that protect
// against specific mutations). Two tests eval _bcIsDesktop in isolation
// (a pure function that only reads window.*) using new Function so Node
// can run it without a browser. Screen.js wiring is verified by scanning
// the import declaration and checking that moved symbols are gone from the
// IIFE.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BC_PANEL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bc-panel.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() { if (!_src) _src = fs.readFileSync(BC_PANEL_JS, 'utf8'); return _src; }
let _screenSrc;
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
// ── Eval helper for _bcIsDesktop (pure; only reads window.*) ──────────────────
let _isDesktopFn;
function isDesktopFn() {
if (_isDesktopFn) return _isDesktopFn;
// Strip 'export' keywords; extract just _bcIsDesktop body for eval.
const stripped = src().replace(/^export\s+/gm, '');
// Wrap in a factory that returns _bcIsDesktop after evaluating the whole
// module (so inner references resolve). window is the only global needed.
const factory = new Function('window', stripped + '\nreturn _bcIsDesktop;');
_isDesktopFn = factory;
return _isDesktopFn;
}
// ── 1. _bcLoading reset on rejection ─────────────────────────────────────────
test('_bcLoading reset to null when lib-load promise rejects', () => {
// Mutation: remove `.catch(() => { _bcLoading = null; })` from _bcLoadLib.
// Without it, a rejected load-promise is cached forever; every subsequent
// mount returns the rejected promise immediately → Butterchurn permanently
// disabled for the session with no retry.
assert.match(src(), /_bcLoading\s*=\s*null/,
'_bcLoadLib must reset _bcLoading to null in a .catch handler so failed loads retry');
// Verify the reset appears in a .catch context (not just an early-return path).
assert.match(src(), /\.catch\s*\([\s\S]{1,60}_bcLoading\s*=\s*null/,
'_bcLoading = null must appear inside a .catch callback');
});
// ── 2. window.h3dBcApplySettings at module scope ──────────────────────────────
test('window.h3dBcApplySettings is assigned at module scope (not inside a function)', () => {
// Mutation: move assignment inside _bcCreateController → it isn't available
// until first mount; settings.html's `?.` call silently no-ops → settings
// changes (opacity, enabled, cycle mode) never apply until the user visits
// the player for the first time.
//
// Structural check: the assignment must appear BEFORE the first `function`
// or `export function` declaration in bc-panel.js (i.e. at module scope).
const s = src();
// Line-anchored regex: `^window.` matches only an unindented assignment.
// A comment mention or an indented assignment (inside a function body) both
// fail to match and return -1 — so this single check is sufficient.
const assignIdx = s.search(/^window\.h3dBcApplySettings\s*=/m);
assert.ok(assignIdx >= 0,
'window.h3dBcApplySettings must be assigned at line-start (module scope) in bc-panel.js — ' +
'an indented assignment (inside a function) would not match /^window\\./m');
});
// ── 3. _bcIsDesktop guards all three required conditions ──────────────────────
test('_bcIsDesktop checks isDesktop, .audio, and typeof getRawAudioFrame', () => {
// Mutation: remove any one guard → non-desktop host (Docker/web app) enters
// the desktop guitar-feed path → audioProvider is wrong; pcmLoop errors
// every 16 ms trying to call an undefined getRawAudioFrame.
const s = src();
assert.match(s, /d\.isDesktop/,
'_bcIsDesktop must guard on d.isDesktop');
assert.match(s, /d\.audio/,
'_bcIsDesktop must guard on d.audio');
assert.match(s, /typeof\s+d\.audio\.getRawAudioFrame\s*===\s*'function'/,
"_bcIsDesktop must guard typeof d.audio.getRawAudioFrame === 'function'");
});
// ── 4. _bcIsDesktop eval: returns false when window has no desktop bridge ─────
test('_bcIsDesktop returns false when window.feedBackDesktop is absent', () => {
// Mutation: remove the `d && ...` guard → accessing .isDesktop on undefined
// throws in the browser; the whole highway init crashes.
const fn = isDesktopFn()({ feedBackDesktop: undefined, slopsmithDesktop: undefined });
assert.strictEqual(fn(), false,
'_bcIsDesktop must return false when neither feedBackDesktop nor slopsmithDesktop is set');
});
// ── 5. _bcIsDesktop eval: returns false when isDesktop is missing from bridge ─
test('_bcIsDesktop returns false when bridge has audio but no isDesktop flag', () => {
// Mutation: rely on truthy bridge presence alone (drop isDesktop check) → any host
// that exposes feedBackDesktop for non-guitar purposes (e.g. file manager) would
// be misidentified as the guitar-input desktop host.
const fn = isDesktopFn()({
feedBackDesktop: { audio: { getRawAudioFrame: () => new Float32Array(512) } },
slopsmithDesktop: undefined,
});
assert.strictEqual(fn(), false,
'_bcIsDesktop must return false when feedBackDesktop lacks the isDesktop flag');
});
// ── 6. destroy() removes controller from _bcControllers ──────────────────────
test('destroy() calls _bcControllers.delete(ctrl)', () => {
// Mutation: remove delete call → dead controller stays in _bcControllers;
// _bcApplyAll iterates the Set and calls applySettings() on the dead controller;
// null canvas/scrim refs throw; multiple repeated mounts eventually saturate Set.
assert.match(src(), /_bcControllers\.delete\s*\(\s*ctrl\s*\)/,
'destroy() must call _bcControllers.delete(ctrl) to remove the dead controller');
});
// ── 7. _bcReleaseCanvasGL called in destroy() ─────────────────────────────────
test('_bcReleaseCanvasGL is called inside destroy()', () => {
// Mutation: remove the release call from destroy() → WebGL context not freed on
// dismount; browsers allow ~16 concurrent contexts; repeated mount/toggles
// exhaust the cap; subsequent mounts get null from getContext('webgl') →
// Butterchurn init silently fails.
//
// The call appears in two places: destroy() method and the .catch error handler.
// Both are required. This test checks that destroy() includes it.
const s = src();
// `destroy() {` (with space+brace) anchors the actual method definition,
// not comment references like "so destroy() closes only ...".
const destroyIdx = s.indexOf('destroy() {');
assert.ok(destroyIdx >= 0, 'destroy() method must exist in the returned controller object');
// Find the release call after the destroy label (within 1000 chars).
const destroyBlock = s.slice(destroyIdx, destroyIdx + 1000);
assert.match(destroyBlock, /_bcReleaseCanvasGL/,
'_bcReleaseCanvasGL must be called inside destroy() to free the WebGL context');
});
// ── 8. _bcReleaseCanvasGL called in async-init failure handler ────────────────
test('_bcReleaseCanvasGL is called in the _bcLoadLib().catch handler', () => {
// Mutation: remove from .catch → half-initialised failure (lib load, WebGL
// context creation) leaves a bound WebGL context on the abandoned canvas;
// the context is never freed; same cap exhaustion as above.
const s = src();
// The error handler's .catch takes a named error param (e) and logs via
// console.error — that's how we distinguish it from the narrow no-op catches.
// Look for `_bcReleaseCanvasGL` inside a `.catch((e) => {` block.
assert.match(s, /\.catch\s*\(\s*\(e\)[\s\S]{1,600}_bcReleaseCanvasGL/,
'_bcReleaseCanvasGL must appear in the error-handler .catch((e) => {}) block');
});
// ── 9. _bcLoadSettings merges saved state with BC_DEFAULTS ───────────────────
test('_bcLoadSettings uses Object.assign with BC_DEFAULTS as base', () => {
// Mutation: return raw parsed JSON without merging → missing keys from
// localStorage (fresh install, partial save) become undefined;
// s.enabled → undefined → bg disabled on first launch.
assert.match(src(), /Object\.assign\s*\(\s*\{\s*\}\s*,\s*BC_DEFAULTS/,
'_bcLoadSettings must merge with BC_DEFAULTS so missing keys get defaults');
});
// ── 10. screen.js imports both exports from src/bc-panel.js ──────────────────
test('screen.js imports _bcCreateController and _bcIsDesktop from src/bc-panel.js', () => {
// Mutation: remove import → H-section call to _bcCreateController throws
// ReferenceError at the first butterchurn mount; the 3D highway becomes
// permanently broken when butterchurn bg is selected.
const s = screenSrc();
assert.match(s,
/import\s+\{[^}]*_bcCreateController[^}]*\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/,
'screen.js must import _bcCreateController from ./src/bc-panel.js');
assert.match(s,
/import\s+\{[^}]*_bcIsDesktop[^}]*\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/,
'screen.js must import _bcIsDesktop from ./src/bc-panel.js');
});
// ── 12. screen.js has no bare caller references to private bc-panel.js symbols ─
test('every bc-panel.js export referenced in screen.js IIFE is in the import statement', () => {
// Mutation: remove _bcLoadSettings (or any other export) from the screen.js import
// → symbol is exported by bc-panel.js, referenced in the IIFE, but not bound via
// import → ReferenceError at runtime. This is the class Creed HIGH found on
// cut 4 (screen.js:15396-15402: _bcLoadSettings + _bcFfIdx called but not imported).
//
// Method (generalised):
// exported = all _bc* symbols with `export` keyword in bc-panel.js
// imported = all symbols in screen.js's `from './src/bc-panel.js'` import clause
// leaked = exported symbols that appear as bare refs in screen.js IIFE
// but are NOT in imported
// Adding a new export and a new caller without updating the import → leaked is
// non-empty → test RED.
const bcSrc = src();
const scrSrc = screenSrc();
// All exported _bc* symbols from bc-panel.js.
const exported = new Set(
[...bcSrc.matchAll(/^export\s+(?:const|let|var|function)\s+(_bc\w+)/mg)].map(m => m[1])
);
// Symbols actually imported from bc-panel.js in screen.js.
const importMatch = scrSrc.match(/import\s+\{([^}]+)\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/);
const imported = new Set(
importMatch ? importMatch[1].split(',').map(s => s.trim()).filter(Boolean) : []
);
// IIFE body: strip import lines and line comments to avoid false positives.
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
const noComments = noImports.replace(/\/\/[^\n]*/g, '');
// Exported symbols referenced in the IIFE body but absent from the import list.
const leaked = [...exported].filter(
sym => new RegExp('\\b' + sym + '\\b').test(noComments) && !imported.has(sym)
);
assert.deepStrictEqual(leaked, [],
'screen.js references bc-panel.js exports that are not in its import clause: ' +
leaked.join(', ') +
' — add them to the import { … } from \'./src/bc-panel.js\' line in screen.js');
});
// ── 11. screen.js IIFE no longer defines moved B-section symbols ──────────────
test('screen.js IIFE does not redeclare _bcCreateController or _bcLoadLib', () => {
// Mutation: re-add function _bcCreateController() to the IIFE → double definition;
// the IIFE-scope function shadows the imported one inside the factory; bc-panel.js
// private state is split: the IIFE copy has its own _bcControllers, _bcSettings, etc.
// The panel never shows, controller objects leak, applySettings no-ops.
const s = screenSrc();
// Strip import lines so we only scan the IIFE body.
const iife = s.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_bcCreateController\s*\(/,
'IIFE must not redeclare _bcCreateController');
assert.doesNotMatch(iife, /function\s+_bcLoadLib\s*\(/,
'IIFE must not redeclare _bcLoadLib');
});
+367
View File
@@ -0,0 +1,367 @@
// Class-killer tests for src/bg-control.js — h3d-carve-5.
//
// bg-control.js uses a factory export (createBgControl({DI})) because its
// dependencies are IIFE-scope values that cannot be ES-module imports.
// screen.js destructures { _pcAcquire, _pcRelease } from the factory result.
//
// Test strategy:
// - Source-scan tests check structural invariants (critical paths, DI wiring,
// accessor call site, tombstone).
// - Screen.js wiring tests check the import clause and destructure form.
// - Generic stranded-caller test (adapted from bc-panel.js test 12) checks
// that every _pc* symbol in the bg-control.js factory return is also in
// screen.js's createBgControl destructure — a bare _pcFoo reference in the
// IIFE that isn't in the destructure is the same stranded-caller bug class.
// - Construction-order test: createBgControl call must appear AFTER all DI
// definitions in screen.js and BEFORE createFactory.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BG_CONTROL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bg-control.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() { if (!_src) _src = fs.readFileSync(BG_CONTROL_JS, 'utf8'); return _src; }
let _screenSrc;
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
// ── 1. createBgControl is exported (not private) ──────────────────────────────
test('createBgControl is exported from bg-control.js', () => {
// Mutation: remove `export` → screen.js import throws SyntaxError /
// "does not provide an export" at module-graph load time → highway never
// initialises; all 3D-Hwy users see a blank canvas.
assert.match(src(), /^export\s+function\s+createBgControl\s*\(/m,
'createBgControl must be a line-start export function declaration');
});
// ── 2. DI params declared (all five) ─────────────────────────────────────────
test('createBgControl destructures all five DI params', () => {
// Mutation: remove one DI param → that function is `undefined` inside the
// factory → every call to e.g. _bgReadGlobal throws TypeError: not a function.
const s = src();
const sig = s.match(/export\s+function\s+createBgControl\s*\(\s*\{([^}]+)\}/);
assert.ok(sig, 'createBgControl signature must use destructuring params');
const params = sig[1];
assert.match(params, /BG_STYLE_IDS/, 'DI must include BG_STYLE_IDS');
assert.match(params, /_bgReadGlobal/, 'DI must include _bgReadGlobal');
assert.match(params, /_bgSubscribe/, 'DI must include _bgSubscribe');
assert.match(params, /_bgUnsubscribe/, 'DI must include _bgUnsubscribe');
assert.match(params, /getVenueSceneOverride/, 'DI must include getVenueSceneOverride');
});
// ── 3. getVenueSceneOverride() called as function (not captured at construction) ──
test('_pcSync calls getVenueSceneOverride() not _venueSceneOverride directly', () => {
// Mutation: revert beyond-subst 2 to `!!_venueSceneOverride` → factory
// captures the initial `false` at construction time; the accessor is never
// called; Venue-active state is always `false` → UI never goes inert under
// Venue; user can "pick" a background while Venue scene is active but the
// pick goes nowhere because Venue owns the mount.
const s = src();
// Must call the accessor (with parens).
assert.match(s, /getVenueSceneOverride\(\)/,
'_pcSync must call getVenueSceneOverride() rather than capturing the var at construction');
// Must NOT contain the raw closure variable name in executable code (bare, without call
// parens). Strip line comments first so the comment-doc in the file header doesn't fire.
const noLineComments = s.replace(/\/\/[^\n]*/g, '');
assert.doesNotMatch(noLineComments, /\b_venueSceneOverride\b/,
'bg-control.js must not reference bare _venueSceneOverride in code — use getVenueSceneOverride()');
});
// ── 4. _bgSubscribe called inside _pcMount ────────────────────────────────────
test('_bgSubscribe is called inside _pcMount to register the settings listener', () => {
// Mutation: remove _bgSubscribe call → control mounts but never receives
// settings-bus events; a style change from Settings page never syncs back
// to the in-player picker; the two UIs drift permanently.
const s = src();
const mountIdx = s.indexOf('function _pcMount()');
assert.ok(mountIdx >= 0, '_pcMount must be defined in bg-control.js');
const mountBlock = s.slice(mountIdx, mountIdx + 6000); // _bgSubscribe ~5200 chars in
assert.match(mountBlock, /_bgSubscribe\s*\(/,
'_bgSubscribe must be called inside _pcMount to register the listener');
});
// ── 5. _bgUnsubscribe called inside _pcTeardownDom ───────────────────────────
test('_bgUnsubscribe is called inside _pcTeardownDom to deregister the listener', () => {
// Mutation: remove _bgUnsubscribe call → listener closure outlives the control;
// after release the stale closure still calls _pcSync on every settings change;
// null refs (_pcSel etc.) throw on first setting write post-teardown.
const s = src();
const teardownIdx = s.indexOf('function _pcTeardownDom()');
assert.ok(teardownIdx >= 0, '_pcTeardownDom must be defined in bg-control.js');
const teardownBlock = s.slice(teardownIdx, teardownIdx + 500);
assert.match(teardownBlock, /_bgUnsubscribe\s*\(/,
'_bgUnsubscribe must be called inside _pcTeardownDom to remove the listener');
});
// ── 6. _pcRelease calls _pcTeardownDom ───────────────────────────────────────
test('_pcRelease calls _pcTeardownDom when refcount reaches zero', () => {
// Mutation: remove _pcTeardownDom() call from _pcRelease → DOM node is
// never removed; the settings listener stays alive; under splitscreen each
// renderer destroys independently but the control never disappears → orphaned
// picker remains visible and partially interactive after 3D-Hwy is deselected.
const s = src();
const releaseIdx = s.indexOf('function _pcRelease()');
assert.ok(releaseIdx >= 0, '_pcRelease must be defined in bg-control.js');
const releaseBlock = s.slice(releaseIdx, releaseIdx + 1200); // _pcTeardownDom ~1040 chars in
assert.match(releaseBlock, /_pcTeardownDom\s*\(\s*\)/,
'_pcRelease must call _pcTeardownDom() when refcount reaches zero');
});
// ── 7. _pcAcquire and _pcRelease returned from factory ───────────────────────
test('createBgControl returns { _pcAcquire, _pcRelease }', () => {
// Mutation: remove either from return → screen.js destructure gets undefined;
// first call to _pcAcquire / _pcRelease from init()/destroy() throws
// TypeError: not a function → highway init crashes on every song load.
const s = src();
const returnMatch = s.match(/return\s*\{([^}]+)\}/);
assert.ok(returnMatch, 'createBgControl must have a return { ... } statement');
const returned = returnMatch[1];
assert.match(returned, /_pcAcquire/, 'createBgControl must return _pcAcquire');
assert.match(returned, /_pcRelease/, 'createBgControl must return _pcRelease');
});
// ── 8. screen.js imports createBgControl from src/bg-control.js ──────────────
test('screen.js imports createBgControl from src/bg-control.js', () => {
// Mutation: remove import → createBgControl is undefined in the IIFE;
// the destructure const { _pcAcquire, _pcRelease } = createBgControl({...})
// throws TypeError at module eval time → plugin never loads.
assert.match(screenSrc(),
/import\s+\{[^}]*createBgControl[^}]*\}\s+from\s+['"]\.\/src\/bg-control\.js['"]/,
'screen.js must import createBgControl from ./src/bg-control.js');
});
// ── 9. screen.js calls createBgControl with all five DI args ─────────────────
test('screen.js passes all five DI arguments to createBgControl', () => {
// Mutation: omit one DI arg → the corresponding param is `undefined` inside
// the factory closure; first call to it (on mount, on settings change) throws.
const s = screenSrc();
const callMatch = s.match(/createBgControl\s*\(\s*\{([^}]+)\}/);
assert.ok(callMatch, 'screen.js must call createBgControl({...})');
const args = callMatch[1];
assert.match(args, /BG_STYLE_IDS/, 'createBgControl call must pass BG_STYLE_IDS');
assert.match(args, /_bgReadGlobal/, 'createBgControl call must pass _bgReadGlobal');
assert.match(args, /_bgSubscribe/, 'createBgControl call must pass _bgSubscribe');
assert.match(args, /_bgUnsubscribe/, 'createBgControl call must pass _bgUnsubscribe');
assert.match(args, /getVenueSceneOverride/, 'createBgControl call must pass getVenueSceneOverride');
});
// ── 10. screen.js IIFE does not redefine _pcAcquire or _pcRelease ────────────
test('screen.js IIFE does not redeclare _pcAcquire or _pcRelease', () => {
// Mutation: re-add `function _pcAcquire()` to the IIFE → IIFE-scope function
// shadows the destructured import; the factory's _pcRelease holds a stale
// closure over the old _pcRefs; refcount goes out of sync; the control
// never unmounts.
const s = screenSrc();
const iife = s.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_pcAcquire\s*\(/,
'IIFE must not redeclare _pcAcquire');
assert.doesNotMatch(iife, /function\s+_pcRelease\s*\(/,
'IIFE must not redeclare _pcRelease');
});
// ── 11. Construction order: createBgControl called before createFactory ───────
test('createBgControl call appears before createFactory in screen.js', () => {
// Mutation: move createBgControl call inside createFactory → each renderer
// instance gets its own independent control (refcount broken across instances);
// or if moved after createFactory but before register, correct for
// single-instance but still wrong order risk. This test ensures the call
// stays at module scope BEFORE the factory.
const s = screenSrc();
const bgCallIdx = s.indexOf('createBgControl(');
const factoryIdx = s.indexOf('function createFactory()');
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
assert.ok(factoryIdx >= 0, 'createFactory must exist in screen.js');
assert.ok(bgCallIdx < factoryIdx,
'createBgControl must be called before createFactory in screen.js');
});
// ── 12. Construction order: DI values defined before createBgControl call ─────
test('all DI values are defined before the createBgControl call in screen.js', () => {
// Mutation: move createBgControl call before BG_STYLE_IDS / _bgReadGlobal /
// _bgSubscribe / _bgUnsubscribe / getVenueSceneOverride binding →
// undefined passed as DI params; factory closure captures undefined → TypeError.
const s = screenSrc();
const bgCallIdx = s.indexOf('createBgControl(');
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
const bgStyleIdsIdx = s.indexOf('BG_STYLE_IDS =');
const bgReadIdx = s.indexOf('function _bgReadGlobal(');
const bgSubIdx = s.indexOf('function _bgSubscribe(');
const venueIdx = s.indexOf('let _venueSceneOverride');
assert.ok(bgStyleIdsIdx < bgCallIdx, 'BG_STYLE_IDS must be defined before createBgControl call');
assert.ok(bgReadIdx < bgCallIdx, '_bgReadGlobal must be defined before createBgControl call');
assert.ok(bgSubIdx < bgCallIdx, '_bgSubscribe must be defined before createBgControl call');
assert.ok(venueIdx < bgCallIdx, '_venueSceneOverride must be defined before createBgControl call');
});
// ── 13. Stranded-caller: every returned symbol must be in screen.js destructure ─
test('every _pc* symbol returned by createBgControl is in the screen.js destructure', () => {
// For a factory module the stranded-caller class is: a symbol in the factory's
// `return { ... }` that is NOT in the screen.js `const { ... } = createBgControl(...)`
// destructure — the factory vends it but screen.js never binds it, so any IIFE
// code that tries to call it hits ReferenceError.
//
// Mutation: add `_pcNewFn` to bg-control.js return {...} but not to screen.js
// destructure → leaked = ['_pcNewFn'] → RED.
const bgSrc = src();
const scrSrc = screenSrc();
// Symbols in the return { ... } of createBgControl.
const returnMatch = bgSrc.match(/return\s*\{([^}]+)\}/);
assert.ok(returnMatch, 'bg-control.js must have a return { ... } statement');
const returned = new Set(
returnMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
// Symbols in the screen.js destructure.
const destructureMatch = scrSrc.match(/const\s*\{\s*([^}]+)\}\s*=\s*createBgControl\s*\(/);
assert.ok(destructureMatch, 'screen.js must destructure the createBgControl result');
const destructured = new Set(
destructureMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
// Every returned symbol must be bound by the destructure (return ⊆ destructure).
const leaked = [...returned].filter(sym => !destructured.has(sym));
assert.deepStrictEqual(leaked, [],
'createBgControl returns symbols not bound by screen.js destructure: ' +
leaked.join(', '));
});
// ── 14. Stale-private guard: no private bg-control.js symbol bare in screen.js ─
test('no private bg-control.js symbol appears bare in screen.js IIFE body', () => {
// The cut-4 stale-private-reference class: a function or variable from a moved
// module that still appears as a bare name in screen.js (not via the destructure,
// not inside an import line, not inside a comment). If bg-control.js is re-merged
// or a caller copy-pastes `_pcSync(...)` into screen.js, this test goes RED.
//
// Mutation: add `_pcSync()` somewhere in screen.js IIFE body (outside the
// createBgControl destructure line) → stale = ['_pcSync'] → RED.
const bgSrc = src();
const scrSrc = screenSrc();
// All _pc* identifiers in bg-control.js. Full scan (not just declaration
// syntax) so multi-var lets like `let _pcEl, _pcSel, _pcReactive, ...`
// on a single line are all captured — the previous declaration-only regex
// only matched the first id per statement.
const defined = new Set(
[...bgSrc.matchAll(/\b(_pc\w+)\b/g)].map(m => m[1])
);
// Public symbols (in the destructure) are legitimately referenced in screen.js.
const destructureMatch = scrSrc.match(/const\s*\{\s*([^}]+)\}\s*=\s*createBgControl\s*\(/);
assert.ok(destructureMatch, 'screen.js must destructure the createBgControl result');
const destructured = new Set(
destructureMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
const privateSymbols = [...defined].filter(sym => !destructured.has(sym));
// Strip imports, block comments (tombstone), line comments, and the destructure
// statement itself so the bound symbols don't fire false positives.
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
const noBlockComments = noImports.replace(/\/\*[\s\S]*?\*\//g, '');
const noLineComments = noBlockComments.replace(/\/\/[^\n]*/g, '');
const noDestructure = noLineComments.replace(
/const\s*\{[^}]+\}\s*=\s*createBgControl\s*\([^)]*\)\s*;/, '',
);
const stale = privateSymbols.filter(
sym => new RegExp('\\b' + sym + '\\b').test(noDestructure),
);
assert.deepStrictEqual(stale, [],
'screen.js contains bare references to private bg-control.js symbols: ' +
stale.join(', '));
});
// ── 15. Literal-table pin: _PC_C colors, _PC_PILL CSS, _PC_LABELS, _PC_USES ──
test('bg-control.js literal tables match known-good values', () => {
// Mutation: any single literal change in _PC_C, _PC_PILL, _PC_LABELS, or
// _PC_USES (e.g. idle '#181830' → '#181831', or intensity: true → false for
// a style that should react to audio) → assertion fails → RED.
// These are inline-styled player-chrome pills whose correctness is invisible
// to runtime tests; without a pin a visual regression ships silently.
const s = src();
// ── _PC_C: inline color tokens from tailwind.config.js ────────────────────
const PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500
onBg: 'rgba(20,83,45,0.5)',// bg-green-900/50
onText: '#86efac', // text-green-300
};
for (const [key, val] of Object.entries(PC_C)) {
assert.ok(s.includes(`${key}: '${val}'`),
`_PC_C.${key} must equal '${val}'`);
}
// ── _PC_PILL: pill-button CSS ──────────────────────────────────────────────
for (const frag of [
'padding:.375rem .75rem',
'border-radius:.5rem',
'font-size:.75rem',
'cursor:pointer',
]) {
assert.ok(s.includes(frag), `_PC_PILL must contain "${frag}"`);
}
// ── _PC_LABELS: display names for each background style ───────────────────
const LABELS = {
off: 'Off',
particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)',
lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image',
video: 'Custom video',
};
for (const [key, label] of Object.entries(LABELS)) {
assert.ok(s.includes(`${key}: '${label}'`) || s.includes(`${key}: "${label}"`),
`_PC_LABELS.${key} must equal '${label}'`);
}
// ── _PC_USES: which controls each style enables ────────────────────────────
// [style, intensity, reactive]
const USES = [
['off', false, false],
['particles', true, true],
['silhouettes', true, true],
['lights', true, true],
['geometric', true, true],
['image', true, false],
['video', false, false],
['butterchurn', false, false],
['venue', false, false],
];
const usesBlock = s.match(/const _PC_USES\s*=\s*\{([\s\S]*?)\n\s*\};/);
assert.ok(usesBlock, '_PC_USES table must be present in bg-control.js');
const usesBody = usesBlock[1];
for (const [style, intensity, reactive] of USES) {
const entry = usesBody.match(new RegExp(style + '\\s*:\\s*\\{([^}]+)\\}'));
assert.ok(entry, `_PC_USES must contain '${style}' entry`);
const block = entry[1];
assert.match(block, new RegExp('intensity:\\s*' + intensity),
`_PC_USES.${style}.intensity must be ${intensity}`);
assert.match(block, new RegExp('reactive:\\s*' + reactive),
`_PC_USES.${style}.reactive must be ${reactive}`);
}
});
@@ -0,0 +1,363 @@
// Regression coverage for the first-chart-data camera bootstrap in
// plugins/highway_3d/screen.js.
//
// The event selector is pure and tested behaviourally. The renderer lifecycle
// wiring remains source-level, matching the existing highway_3d camera tests:
// constructing a full Three.js renderer in Node would test a large fake DOM/GL
// harness rather than the bootstrap contract itself.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
// h3d-carve-15: U-section (bootstrap region C) moved to renderer.js; scan both.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// Since h3d-carve-1b, hwyFirstRelevantFrettedTime lives in geometry.js.
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
const geoSrc = fs.readFileSync(GEOMETRY_JS, 'utf8');
// h3d-carve-9: camUpdate body moved to camera.js — extractFn retargets there.
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
function extractFn(source, name) {
const start = source.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist`);
const open = source.indexOf('{', start);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1);
}
throw new Error(`unbalanced braces extracting ${name}`);
}
function sourceBetween(startText, endText) {
const start = src.indexOf(startText);
assert.ok(start >= 0, `missing source anchor: ${startText}`);
const end = src.indexOf(endText, start);
assert.ok(end > start, `missing source end anchor: ${endText}`);
return src.slice(start, end);
}
const hwyFirstRelevantFrettedTime = new Function(
'"use strict";'
+ extractFn(geoSrc, 'hwyFirstRelevantFrettedTime')
+ '\nreturn hwyFirstRelevantFrettedTime;',
)();
test('long intros bootstrap from the earliest future fretted note', () => {
const notes = [
{ t: 13.22, s: 2, f: 7 },
{ t: 15.0, s: 1, f: 4 },
];
const chords = [
{ t: 14.0, notes: [{ s: 0, f: 3 }, { s: 1, f: 5 }] },
];
assert.equal(hwyFirstRelevantFrettedTime(notes, chords, 0.4, 0.2, 6), 13.22);
});
test('chord-only charts bootstrap from fretted chord members', () => {
const chords = [
{ t: 4.0, notes: [{ s: 0, f: 0 }, { s: 1, f: 0 }] },
{ t: 8.5, notes: [{ s: 0, f: 0 }, { s: 1, f: 9 }] },
];
assert.equal(hwyFirstRelevantFrettedTime([], chords, 0, 0.2, 6), 8.5);
});
test('empty and all-open charts keep the default camera', () => {
assert.equal(hwyFirstRelevantFrettedTime([], [], 0, 0.2, 6), null);
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 2, s: 0, f: 0 }],
[{ t: 3, notes: [{ s: 1, f: 0 }, { s: 2, f: 0 }] }],
0,
0.2,
6,
), null);
});
test('bootstrap ignores malformed strings but supports extended-range charts', () => {
const notes = [
{ t: 1, s: -1, f: 4 },
{ t: 2, s: 7, f: 5 },
{ t: 3, s: 6, f: 8 },
];
assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 6), null);
assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 7), 3);
});
test('active sustains bootstrap at now and fully expired events are skipped', () => {
const now = 10;
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 6, sus: 5, s: 2, f: 7 }],
[],
now,
0.2,
6,
), now);
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 6, sus: 1, s: 2, f: 7 }, { t: 15, s: 2, f: 9 }],
[],
now,
0.2,
6,
), 15);
});
test('recent onsets inside the behind-window bootstrap at now', () => {
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 9.9, s: 2, f: 7 }],
[],
10,
0.2,
6,
), 10);
});
test('bootstrap runs once when complete chart arrays arrive', () => {
const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)',
' pbBeg(4);',
);
assert.match(
bootstrap,
/if\s*\(\s*!getCamSnapped\s*\(\s*\)\s*&&\s*!getCamPreScanned\s*\(\s*\)\s*&&\s*notes\s*&&\s*chords\s*\)/,
'chart bootstrap must be gated to one pass after both arrays arrive',
);
assert.match(
bootstrap,
/hwyFirstRelevantFrettedTime\(\s*notes\s*,\s*chords\s*,\s*now\s*,\s*CAM_TGT_BEHIND\s*,\s*nStr\s*\)/,
'bootstrap must select the first relevant event using the active string count',
);
assert.match(
bootstrap,
/firstFrettedTime\s*===\s*null[\s\S]*?setCamSnapped\s*\(\s*true\s*\)/,
'all-open/empty charts without lookahead bounds must permanently disable bootstrap work',
);
});
test('steady and lookahead modes initialize immediately from future chart data', () => {
const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)',
' pbBeg(4);',
);
assert.match(
bootstrap,
/cameraMode\s*===\s*'lookahead'[\s\S]*?lookaheadBoundsNow\s*\|\|\s*firstFrettedTime\s*!==\s*null/,
'lookahead anchor bounds must bootstrap even on an all-open chart',
);
assert.match(
bootstrap,
/lookaheadBootstrapTime\(\s*now\s*,\s*firstFrettedTime\s*\)/,
'lookahead mode must project to the first window that reaches the phrase',
);
assert.match(
bootstrap,
/lookaheadBoundsNow\s*\?\s*now\s*:\s*lookaheadBootstrapTime/,
'already-live anchor/note bounds must win over a projected lookahead',
);
assert.match(
bootstrap,
/Math\.max\(\s*now\s*,\s*firstFrettedTime\s*-\s*camAhead\s*\)/,
'steady mode must sample when the first event enters its normal target window',
);
assert.match(
bootstrap,
/setCurX\s*\(\s*getTgtX\s*\(\)\s*\)\s*;[\s\S]*?setCurDist\s*\(\s*getTgtDist\s*\(\)\s*\)\s*;/,
'the initial base position must be applied before the note draw loop',
);
});
test('silent-intro hold hands off only when live framing is ready', () => {
const target = sourceBetween(
'// ── Camera target',
'// ── Chord diagram:',
);
assert.match(
target,
/cameraMode\s*===\s*'lookahead'\s*\?\s*lookaheadBoundsNow\s*!==\s*null\s*:\s*camDistGot/,
'lookahead and steady modes must use their own live-ready signal',
);
assert.match(
target,
/if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*getPrevLockActive\s*\(\s*\)/,
'the bootstrap target must remain untouched while the live window is empty',
);
assert.match(
target,
// h3d-carve-15: bare assignments → setter calls in renderer.js
/getCamBootstrapMode\(\)\s*!==\s*cameraMode[\s\S]*?setCamBootstrapHolding\s*\(\s*false\s*\)/,
'a live camera-mode change must safely release the old-mode hold',
);
});
test('song changes and teardown reset every bootstrap state field', () => {
// h3d-carve-15: song-change path uses setter calls (renderer.js);
// teardown/init path uses bare assignments (screen.js). Both must exist.
const setterResets = src.match(
/setCamSnapped\s*\(\s*false\s*\)\s*;\s*\r?\n\s*setCamPreScanned\s*\(\s*false\s*\)/g,
) || [];
const bareResets = src.match(
/_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false/g,
) || [];
const totalResets = setterResets.length + bareResets.length;
assert.equal(
totalResets,
2,
`song-change and teardown paths must both reset bootstrap state (setter=${setterResets.length}, bare=${bareResets.length})`,
);
});
test('Camera Director still layers after the bootstrapped auto-framing base', () => {
const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)',
' pbBeg(4);',
);
assert.doesNotMatch(
bootstrap,
/_freeCam|__h3dCamCtl/,
'bootstrap must only initialize base framing, never mutate Camera Director state',
);
// h3d-carve-9: extractFn must target cameraSrc — src holds only the tombstone.
// tgtX is DI-rewired to getTgtX() direct call in camera.js.
const camUpdate = extractFn(cameraSrc, 'camUpdate');
const baseIndex = camUpdate.indexOf('curX += (getTgtX() - curX) * lerp');
const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)');
const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)');
assert.ok(
baseIndex >= 0 && directorIndex > baseIndex && positionIndex > directorIndex,
'Camera Director transforms must remain layered after base framing and before camera placement',
);
});
// ── h3d-carve-9: setter class-killers (write-back pairs must survive DI) ────
// Severing the call turns the test RED: a silent local var replaces the
// write-back and the IIFE-scope var is never updated.
test('setCurX write-back is called in camUpdate (curX persists across frames)', () => {
// Silencing: sed 's/setCurX(curX)/\/\/ GUTTED/' → this test fails.
assert.match(
cameraSrc,
/setCurX\(\s*curX\s*\)/,
'camUpdate must write curX back via setCurX(); removing it silences the update',
);
});
test('setFretRowFitBoost write-back is called in camUpdate (boost persists across frames)', () => {
// Silencing: sed 's/setFretRowFitBoost(_fretRowFitBoost)/\/\/ GUTTED/' → RED.
assert.match(
cameraSrc,
/setFretRowFitBoost\(\s*_fretRowFitBoost\s*\)/,
'camUpdate must write _fretRowFitBoost back via setFretRowFitBoost(); removing it silences the boost',
);
});
// ── h3d-carve-9 Creed r1: naming-correspondence guard (param-swap class-killer) ──
// Structural source-scan: every entry in createCamera({...}) must satisfy its
// naming-correspondence class. Kills swaps like (CAM_H_BASE: CAM_DIST_BASE) and
// wrong-var getters ((getCurX: () => curDist)) across the whole wiring surface.
// cut-13 additions inherit the guard automatically; only the pinned fn-ref renames
// need a one-line entry in PINNED_RENAMES when a new rename is introduced.
test('createCamera({...}) wiring has correct naming correspondence (no param swaps)', () => {
// Fn-ref renames that intentionally differ from shorthand — pinned exhaustively.
const PINNED_RENAMES = {
freeCamFor: '_freeCamFor',
aspectPaneKey: '_aspectPaneKey',
resolveTuneFor: '_resolveTuneFor',
aspectRegisterPane: '_aspectRegisterPane',
};
// 1. Extract the argument block from the createCamera call.
// h3d-carve-13: destructure expanded with lookahead exports — update anchor string.
const ANCHOR = 'const { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX } = createCamera({';
const callStart = src.indexOf(ANCHOR);
assert.ok(callStart >= 0, 'createCamera call must be findable in screen.js');
const blockStart = callStart + ANCHOR.length - 1; // points to the opening {
assert.equal(src[blockStart], '{', 'expected { at computed blockStart');
let depth = 0, blockEnd = -1;
for (let i = blockStart; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) { blockEnd = i; break; }
}
assert.ok(blockEnd > blockStart, 'createCamera argument block must have balanced braces');
const inner = src.slice(blockStart + 1, blockEnd);
// 2. Split into entries at depth-0 commas (setter bodies contain { } — skip them).
const rawEntries = [];
let current = '', d = 0;
for (let i = 0; i < inner.length; i++) {
const ch = inner[i];
if (ch === '{') d++;
else if (ch === '}') d--;
if (ch === ',' && d === 0) {
const t = current.trim();
if (t) rawEntries.push(t);
current = '';
} else {
current += ch;
}
}
if (current.trim()) rawEntries.push(current.trim());
// Strip line comments and blank entries.
const entries = rawEntries
.map(e => e.replace(/\/\/[^\n]*/g, '').trim())
.filter(Boolean);
assert.ok(entries.length >= 48, `expected at least 48 entries, got ${entries.length}`);
const violations = [];
for (const entry of entries) {
if (!entry.includes(':')) {
// Shorthand — key === value by definition (BASE_VFOV, sY, …).
continue;
}
const colonIdx = entry.indexOf(':');
const key = entry.slice(0, colonIdx).trim();
const value = entry.slice(colonIdx + 1).trim();
if (key in PINNED_RENAMES) {
if (value !== PINNED_RENAMES[key]) {
violations.push(`${key}: pinned to '${PINNED_RENAMES[key]}' but got '${value}'`);
}
continue;
}
if (key.startsWith('get')) {
// () => [_]varStem — varStem (no underscore) must match key minus 'get' prefix.
const expectedStem = key[3].toLowerCase() + key.slice(4);
const m = value.match(/^\(\)\s*=>\s*_?(\w+)$/);
if (!m) {
violations.push(`${key}: getter value '${value}' does not match () => [_]var`);
continue;
}
if (m[1] !== expectedStem) {
violations.push(`${key}: getter body references var stem '${m[1]}' but expected '${expectedStem}'`);
}
continue;
}
if (key.startsWith('set')) {
// (v) => { [_]varStem = v; } — varStem must match key minus 'set' prefix.
const expectedStem = key[3].toLowerCase() + key.slice(4);
const m = value.match(/^\(v\)\s*=>\s*\{\s*_?(\w+)\s*=\s*v\s*;\s*\}$/);
if (!m) {
violations.push(`${key}: setter value '${value}' does not match (v) => { [_]var = v; }`);
continue;
}
if (m[1] !== expectedStem) {
violations.push(`${key}: setter assigns var stem '${m[1]}' but expected '${expectedStem}'`);
}
continue;
}
// key:value form that is NOT a getter, setter, or pinned rename — disallowed.
violations.push(`${key}: key:value entry not in PINNED_RENAMES and not a get/set arrow`);
}
assert.deepEqual(violations, [], `createCamera wiring violations found`);
});
+34 -19
View File
@@ -23,7 +23,12 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
// h3d-carve-15: U-section moved to renderer.js; scan both.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// h3d-carve-9: camUpdate body moved here; tests that pin its internals retarget to cameraSrc.
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
// ── Zoom-dependent framing ──────────────────────────────────────────────────
@@ -43,13 +48,14 @@ test('cam.position uses interpolated framing multipliers, not literals', () => {
// The base position is assigned into _camX/_camY/_camZ so the opt-in
// free-camera bridge (#771) can layer orbit/zoom/height on top before the
// single cam.position.set; the multipliers must still feed _camY/_camZ.
// h3d-carve-9: camUpdate (and these locals) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/_camX\s*=\s*curX\s*\+\s*shoulderOffset\s*,\s*_camY\s*=\s*h\s*\*\s*_hMul\s*,\s*_camZ\s*=\s*dist\s*\*\s*_dMul/,
'the base camera position must use the interpolated _hMul / _dMul multipliers',
);
assert.match(
src,
cameraSrc,
/cam\.position\.set\(\s*_camX\s*,\s*_camY\s*,\s*_camZ\s*\)/,
'cam.position.set must apply the computed _camX / _camY / _camZ',
);
@@ -57,18 +63,19 @@ test('cam.position uses interpolated framing multipliers, not literals', () => {
test('framing multipliers are a clamped zoom-distance interpolation', () => {
// _zt is clamped to [0,1] and lerps each multiplier between NEAR and FAR.
// h3d-carve-9: camUpdate (and these expressions) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/Math\.max\(0,\s*Math\.min\(1,[\s\S]*?CAM_FRAME_DIST_NEAR[\s\S]*?CAM_FRAME_DIST_FAR/,
'_zt must clamp (dist - NEAR)/(FAR - NEAR) into [0,1]',
);
assert.match(
src,
cameraSrc,
/CAM_FRAME_H_NEAR\s*\+\s*\(\s*CAM_FRAME_H_FAR\s*-\s*CAM_FRAME_H_NEAR\s*\)\s*\*\s*_zt/,
'height multiplier must lerp NEAR->FAR by _zt',
);
assert.match(
src,
cameraSrc,
/CAM_FRAME_D_NEAR\s*\+\s*\(\s*CAM_FRAME_D_FAR\s*-\s*CAM_FRAME_D_NEAR\s*\)\s*\*\s*_zt/,
'depth multiplier must lerp NEAR->FAR by _zt',
);
@@ -85,35 +92,38 @@ test('lookahead window is expressed in measures with a seconds fallback', () =>
test('measure-start cache only keeps beats with measure >= 0', () => {
// Intra-measure beats carry measure === -1 and must be skipped.
// h3d-carve-15: bare _measureStarts = _ms → setMeasureStarts(_ms) in renderer.js
assert.match(
src,
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?_measureStarts\s*=\s*_ms/,
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?setMeasureStarts\s*\(\s*_ms\s*\)/,
'only measure-start beats (measure >= 0) feed _measureStarts',
);
});
test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => {
// h3d-carve-13: lookaheadEndTime moved to src/camera.js — retarget to cameraSrc.
assert.match(
src,
cameraSrc,
/function\s+lookaheadEndTime\s*\(\s*now\s*\)/,
'lookaheadEndTime(now) helper must exist',
);
assert.match(
src,
cameraSrc,
/const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/,
'target measure index = current measure + CAM_LOOKAHEAD_MEASURES',
);
// No beats → seconds fallback.
assert.match(
src,
cameraSrc,
/if\s*\(\s*!ms\s*\|\|\s*ms\.length\s*===\s*0\s*\)\s*return\s+now\s*\+\s*CAM_LOOKAHEAD_SEC/,
'lookaheadEndTime must fall back to seconds when there are no measures',
);
});
test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => {
// h3d-carve-13: lookaheadComputeFretBounds moved to src/camera.js — retarget to cameraSrc.
assert.match(
src,
cameraSrc,
/function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/,
'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)',
);
@@ -123,9 +133,10 @@ test('measure-start cache is invalidated on song change', () => {
// The song-change reset (reconnect path) resets _camSnapped; it must also
// drop the measure-start cache, otherwise lookaheadEndTime sizes the window
// off the previous song's measure grid and over-zooms the first-data snap.
// h3d-carve-15: bare assignments → setter calls in renderer.js
assert.match(
src,
/_camSnapped\s*=\s*false\s*;[\s\S]*?_measureStarts\s*=\s*\[\]\s*;\s*_measureStartsRef\s*=\s*null\s*;/,
/setCamSnapped\s*\(\s*false\s*\)\s*;[\s\S]*?setMeasureStarts\s*\(\s*\[\]\s*\)\s*;\s*setMeasureStartsRef\s*\(\s*null\s*\)\s*;/,
'song-change reset must clear _measureStarts / _measureStartsRef alongside _camSnapped',
);
});
@@ -146,29 +157,32 @@ test('fret-row fit guard constants are defined', () => {
test('the curDist lerp target applies the fit-guard dolly boost', () => {
// The span-driven tgtDist still owns zooming in; the boost only pulls back.
// h3d-carve-9: camUpdate (and this expression) moved to src/camera.js.
// tgtDist is DI-rewired to getTgtDist() direct call.
assert.match(
src,
/curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward tgtDist * _fretRowFitBoost',
cameraSrc,
/curDist\s*\+=\s*\(\s*getTgtDist\(\)\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward getTgtDist() * _fretRowFitBoost',
);
});
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
// h3d-carve-9: camUpdate (and this logic) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
'the guard must probe the same row band the fret-number row is drawn at',
);
// Prompt pull-back when below the min, capped at BOOST_MAX.
assert.match(
src,
cameraSrc,
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
);
// Lazy relax only once past the deadband, floored at 1.
assert.match(
src,
cameraSrc,
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
'past the deadband the boost relaxes back toward 1',
);
@@ -176,8 +190,9 @@ test('the guard projects the fret-row band and adjusts the boost with hysteresis
test('the fit guard yields to the free-cam (Camera Director)', () => {
// When the free-cam owns the view the auto dolly must reset to 1, not fight it.
// h3d-carve-9: camUpdate (and this guard) moved to src/camera.js.
assert.match(
src,
cameraSrc,
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
'with the free-cam enabled the guard must drop any auto dolly back to 1',
);
@@ -0,0 +1,271 @@
// Behavioral kills for h3d-carve-13: S-section lookahead helpers extracted
// into src/camera.js (createCamera). Covers lookaheadEndTime (via callers),
// lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX.
//
// Kill strategy per god's GO:
// lookaheadComputeFretBounds — real-shaped note/chord/anchor fixtures asserting
// concrete min/max fret bounds; gut the bounds loop → RED.
// lookaheadBootstrapTime / lookaheadTargetWorldX — one input→output assert each;
// gut the function → RED.
// lookaheadEndTime (private) — exercised through its callers.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
// ── Source-level checks ────────────────────────────────────────────────────
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
test('camera.js exports createCamera that returns all 5 expected symbols', () => {
const src = fs.readFileSync(CAMERA_JS, 'utf8');
assert.match(src, /return\s*\{[^}]*effectiveVfov/, 'effectiveVfov in return');
assert.match(src, /return\s*\{[^}]*camUpdate/, 'camUpdate in return');
assert.match(src, /return\s*\{[^}]*lookaheadBootstrapTime/, 'lookaheadBootstrapTime in return');
assert.match(src, /return\s*\{[^}]*lookaheadComputeFretBounds/, 'lookaheadComputeFretBounds in return');
assert.match(src, /return\s*\{[^}]*lookaheadTargetWorldX/, 'lookaheadTargetWorldX in return');
});
test('camera.js DI signature includes 9 new h3d-carve-13 params', () => {
const src = fs.readFileSync(CAMERA_JS, 'utf8');
for (const param of [
'NFRETS', 'CAM_LOOKAHEAD_MEASURES', 'CAM_LOOKAHEAD_SEC', 'CAM_FRET_EDGE_BLEND',
'getMeasureStarts', 'validString', 'getChartAnchorAt', 'xFretMid', 'xFret',
]) {
assert.match(src, new RegExp(`\\b${param}\\b`), `param ${param} present in camera.js`);
}
});
test('lookaheadEndTime is NOT exported (factory-private)', () => {
const src = fs.readFileSync(CAMERA_JS, 'utf8');
// Must not appear in the return object
assert.doesNotMatch(
src,
/return\s*\{[^}]*lookaheadEndTime/,
'lookaheadEndTime must not be in the return object',
);
// But must be defined as a function inside the factory
assert.match(src, /function\s+lookaheadEndTime\s*\(/, 'lookaheadEndTime defined in factory');
});
test('screen.js Region A contains the carve comment and not the old function defs', () => {
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Old function defs removed
assert.doesNotMatch(src, /function\s+lookaheadEndTime\s*\(/, 'lookaheadEndTime not in screen.js');
assert.doesNotMatch(src, /function\s+lookaheadBootstrapTime\s*\(/, 'lookaheadBootstrapTime not in screen.js');
assert.doesNotMatch(src, /function\s+lookaheadComputeFretBounds\s*\(/, 'lookaheadComputeFretBounds not in screen.js');
assert.doesNotMatch(src, /function\s+lookaheadTargetWorldX\s*\(/, 'lookaheadTargetWorldX not in screen.js');
// Carve comment present
assert.match(src, /h3d-carve-13.*S-section lookahead helpers/, 'carve-13 comment present');
// Destructure at createCamera call site
assert.match(src, /lookaheadBootstrapTime.*lookaheadComputeFretBounds.*lookaheadTargetWorldX.*=\s*createCamera|createCamera[\s\S]{0,800}lookaheadBootstrapTime/, 'exports destructured from createCamera');
});
// ── Behavioral kills (module-level, import camera.js via dynamic import) ──
// Minimal stub factory matching the 9 new DI params + pre-existing required params.
// Only the fields actually used by the 4 new functions need real values;
// everything else gets a no-op stub so createCamera doesn't throw.
function makeCamera(overrides = {}) {
const fretWidth = 10; // arbitrary unit
// fretX: fret 0 = 0, fret N = N*fretWidth
const fretX = f => f * fretWidth;
const fretMid = f => (f + 0.5) * fretWidth;
return import(`${CAMERA_JS}?t=${Date.now()}`).then(mod => {
return mod.createCamera({
// Pre-existing DI params (stubs — camUpdate not exercised here)
BASE_VFOV: 60, HORPLUS_START_ASPECT: 1.78, HORPLUS_MIN_VFOV: 30,
CAM_LERP_BASE: 0.05, CAM_H_BASE: 1, CAM_DIST_BASE: 10,
CAM_FRAME_DIST_NEAR: 5, CAM_FRAME_DIST_FAR: 20,
CAM_FRAME_H_NEAR: 1, CAM_FRAME_H_FAR: 2,
CAM_FRAME_D_NEAR: 1, CAM_FRAME_D_FAR: 1.5,
FOCUS_D: 8, S_GAP: 1, K: 1,
FRET_ROW_FIT_NDC_MIN: -0.9, FRET_ROW_FIT_DEADBAND: 0.1, FRET_ROW_FIT_BOOST_MAX: 1.5,
CAM_TILT_BAND_T: 0.1, CAM_TILT_BAND_C: 0.3, CAM_TILT_STR_T: 0.5, CAM_TILT_STR_C: 0.2,
getCam: () => ({ fov: 60, updateProjectionMatrix() {}, position: { set() {} }, lookAt() {}, updateMatrixWorld() {} }),
getTgtX: () => 0, getTgtDist: () => 10,
getAspectScale: () => 1, getLeftyCached: () => false,
getNStr: () => 6, getProbe: () => ({ set() {}, project() {}, y: -0.35 }),
getTiltSmoothing: () => 0.5, getPaneAspect: () => 1.78, getPaneUid: () => 'test',
getHighwayCanvas: () => null,
getCurX: () => 0, setCurX: () => {},
getCurDist: () => 10, setCurDist: () => {},
getCurLookY: () => 0, setCurLookY: () => {},
getTgtLookY: () => 0, setTgtLookY: () => {},
getFretRowFitBoost: () => 1, setFretRowFitBoost: () => {},
sY: () => 0, freeCamFor: () => null,
aspectPaneKey: () => 'test', resolveTuneFor: () => null,
aspectRegisterPane: () => {},
// h3d-carve-13: new params
NFRETS: 24,
CAM_LOOKAHEAD_MEASURES: 9,
CAM_LOOKAHEAD_SEC: 3.0,
CAM_FRET_EDGE_BLEND: 0.1,
getMeasureStarts: overrides.getMeasureStarts ?? (() => []),
validString: overrides.validString ?? (s => s >= 0 && s < 6),
getChartAnchorAt: overrides.getChartAnchorAt ?? (() => null),
xFretMid: overrides.xFretMid ?? fretMid,
xFret: overrides.xFret ?? fretX,
});
});
}
// ── lookaheadComputeFretBounds — concrete note/chord/anchor fixtures ────────
test('lookaheadComputeFretBounds: returns null when no notes/chords/anchors', async () => {
const cam = await makeCamera();
const result = cam.lookaheadComputeFretBounds(0, [], [], []);
assert.equal(result, null);
});
test('lookaheadComputeFretBounds: fret bounds from notes array', async () => {
const cam = await makeCamera();
// now=0, CAM_LOOKAHEAD_SEC=3.0 → window [0, 3.0]
const notes = [
{ t: 0.5, s: 0, f: 5 },
{ t: 1.0, s: 1, f: 9 },
{ t: 1.5, s: 2, f: 3 },
{ t: 10, s: 0, f: 1 }, // outside window — should be excluded
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.ok(result !== null, 'should find bounds');
assert.equal(result.minF, 3, 'minF = 3 (fret 3 note at t=1.5)');
assert.equal(result.maxF, 9, 'maxF = 9 (fret 9 note at t=1.0)');
});
test('lookaheadComputeFretBounds: fret bounds from chords', async () => {
const cam = await makeCamera();
const chords = [
{ t: 0.5, notes: [{ s: 0, f: 2 }, { s: 1, f: 7 }] },
{ t: 4.0, notes: [{ s: 0, f: 1 }] }, // outside window
];
const result = cam.lookaheadComputeFretBounds(0, null, null, chords);
assert.ok(result !== null);
assert.equal(result.minF, 2);
assert.equal(result.maxF, 7);
});
test('lookaheadComputeFretBounds: open strings (f=0) are excluded', async () => {
const cam = await makeCamera();
// f=0 means open string — consider() guard: !(f > 0) → skip
const notes = [
{ t: 0.5, s: 0, f: 0 }, // open — must be excluded
{ t: 1.0, s: 1, f: 8 },
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.ok(result !== null);
assert.equal(result.minF, 8, 'open string excluded');
assert.equal(result.maxF, 8);
});
test('lookaheadComputeFretBounds: invalid string filtered by validString', async () => {
let nStr = 6;
const cam = await makeCamera({
validString: s => s >= 0 && s < nStr,
});
const notes = [
{ t: 0.5, s: 99, f: 5 }, // invalid string → skip
{ t: 1.0, s: 2, f: 12 },
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.ok(result !== null);
assert.equal(result.minF, 12, 'invalid-string note excluded');
assert.equal(result.maxF, 12);
});
test('lookaheadComputeFretBounds: anchor data contributes to bounds', async () => {
// Anchor: fret=3, width=4 → frets 3..6
const anchor = { fret: 3, width: 4, time: 0 };
const cam = await makeCamera({
getChartAnchorAt: (_arr, _t) => anchor,
});
// Tiny range so the anchor loop runs [0..3.0] with step 0.125
const result = cam.lookaheadComputeFretBounds(0, [anchor], null, null);
assert.ok(result !== null);
assert.equal(result.minF, 3);
assert.equal(result.maxF, 6);
});
// Kill test: gut the bounds loop → result always null or wrong bounds
test('lookaheadComputeFretBounds: gut-kill — notes outside window excluded (timing boundary)', async () => {
const cam = await makeCamera();
// now=0, window=3s; note at exactly t=3.0+ε should be excluded
const notes = [
{ t: 3.001, s: 0, f: 5 }, // just outside window
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.equal(result, null, 'note beyond window must be excluded');
});
// ── lookaheadBootstrapTime — input→output with gut-kill ────────────────────
test('lookaheadBootstrapTime: returns now when lookahead already covers eventTime', async () => {
const cam = await makeCamera();
// now=0, CAM_LOOKAHEAD_SEC=3.0 → lookaheadEndTime(0)=3.0 ≥ eventTime=2.0
const result = cam.lookaheadBootstrapTime(0, 2.0);
assert.equal(result, 0, 'window already covers event → return now');
});
test('lookaheadBootstrapTime: returns now when eventTime ≤ now', async () => {
const cam = await makeCamera();
const result = cam.lookaheadBootstrapTime(5.0, 4.0);
assert.equal(result, 5.0, 'event in the past → return now');
});
test('lookaheadBootstrapTime: binary search converges on correct bootstrap point', async () => {
// measureStarts at [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// CAM_LOOKAHEAD_MEASURES=9 → from t=0, window end = ms[9] = 18
// eventTime=19 → need to project forward until lookaheadEnd(t) ≥ 19
// lookaheadEnd(2) = ms[2+9] = ms[11]... but ms only has 11 entries [0..10] → extrapolate
// The exact value is tested for being in [0, eventTime) and for t < eventTime.
const ms = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
const cam = await makeCamera({ getMeasureStarts: () => ms });
const bst = cam.lookaheadBootstrapTime(0, 28.0);
// lookaheadEnd(bst) must be >= 28.0
// bst must be > 0 (event is past current window)
assert.ok(bst > 0, 'bootstrap > 0: needed to project forward');
assert.ok(bst < 28.0, 'bootstrap < eventTime');
});
// ── lookaheadTargetWorldX — input→output with gut-kill ─────────────────────
test('lookaheadTargetWorldX: blends fret midpoint with board-weighted X', async () => {
// xFretMid(f) = (f+0.5)*10, xFret(f) = f*10, NFRETS=24
// minF=3, maxF=9:
// middle = (xFretMid(3) + xFretMid(9))/2 = (35 + 95)/2 = 65
// weighted = 0.6*xFret(0) + 0.4*xFret(24) = 0 + 0.4*240 = 96
// wb=0.1 → result = 65*(1-0.1) + 96*0.1 = 58.5 + 9.6 = 68.1
const cam = await makeCamera();
const result = cam.lookaheadTargetWorldX(3, 9);
assert.ok(Math.abs(result - 68.1) < 0.001, `expected ≈68.1, got ${result}`);
});
test('lookaheadTargetWorldX: symmetric fret span centered at board center', async () => {
// With symmetric span (frets 0..24) middle = xFretMid(0)+xFretMid(24))/2 = (5+245)/2=125
// weighted = 0.6*0 + 0.4*240 = 96; wb=0.1 → 125*0.9 + 96*0.1 = 112.5+9.6=122.1
const cam = await makeCamera();
const r1 = cam.lookaheadTargetWorldX(0, 24);
assert.ok(Math.abs(r1 - 122.1) < 0.001, `symmetric span expected ≈122.1, got ${r1}`);
});
// ── Wiring guard: naming-correspondence on the 9 new DI params ────────────
test('createCamera DI params appear in the h3d-carve-13 comment block in screen.js', () => {
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// All 9 new params must appear in the createCamera({}) call block.
// Find the call site (the `const { ... } = createCamera({` line), then
// extract from there to the matching closing `});` — search for the
// h3d-carve-13 comment marker which brackets the new params.
const idx = src.indexOf('const { effectiveVfov, camUpdate, lookaheadBootstrapTime');
assert.ok(idx !== -1, 'createCamera destructure line found');
// Pull enough text to cover the full call (up to 4000 chars is plenty)
const callBlock = src.slice(idx, idx + 4000);
for (const name of [
'NFRETS', 'CAM_LOOKAHEAD_MEASURES', 'CAM_LOOKAHEAD_SEC', 'CAM_FRET_EDGE_BLEND',
'getMeasureStarts', 'validString', 'getChartAnchorAt', 'xFretMid', 'xFret',
]) {
assert.ok(callBlock.includes(name), `${name} present in createCamera({}) call`);
}
});
+9 -5
View File
@@ -14,22 +14,26 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
// h3d-carve-16: DI form uses getRen().domElement instead of ren.domElement
assert.match(src, /(?:getRen\(\)|ren)\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
assert.match(src, /(?:getRen\(\)|ren)\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
'must listen for webglcontextrestored on ren.domElement');
});
test('the context-lost handler preventDefaults and pauses drawing', () => {
// Without preventDefault() the browser will not attempt to restore the
// context and the loss can escalate to a renderer crash.
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
// h3d-carve-16: DI form uses setOnCtxLost((e) => instead of _onCtxLost = (e) =>
const m = src.match(/(?:_onCtxLost\s*=\s*|setOnCtxLost\()\s*\(e\)\s*=>\s*\{[\s\S]*?\}\s*[;)]/);
assert.ok(m, '_onCtxLost handler must exist');
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
// h3d-carve-16: DI form uses setCtxLost(true) instead of _ctxLost = true
assert.match(m[0], /(?:setCtxLost\(true\)|_ctxLost\s*=\s*true)/, 'context-lost handler must set _ctxLost = true');
});
test('draw() early-returns while the context is lost', () => {
@@ -23,11 +23,19 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-14: V-section moved to note-renderer.js; tests that pin its
// patterns must now search both files.
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
let _src;
/** Returns the cached 3D highway screen source under test. */
/** Returns screen.js + note-renderer.js concatenated for pattern matching. */
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
if (!_src) {
_src = fs.readFileSync(SCREEN_JS, 'utf8')
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8')
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
}
return _src;
}
+2 -1
View File
@@ -17,10 +17,11 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
let _src;
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
return _src;
}
+24 -11
View File
@@ -2,8 +2,12 @@
// The board can render fret columns either Uniform (equal width, the chart
// Remastered style) or Logarithmic (real instrument geometry), switchable at
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A
// refactor that renames the storage key, drops the uniform/log branch in
// fretX, or stops validating the mode would silently regress the setting.
// refactor that renames the storage key, drops the delegator in fretX, or
// stops validating the mode would silently regress the setting.
//
// Since h3d-carve-1, the uniform/log branch lives in src/geometry.js
// (geoFretX); screen.js keeps a 1-arg delegator:
// const fretX = f => geoFretX(f, _h3dFretUniform);
//
// Source-level only — same strategy as the other tests/js/ files.
@@ -13,9 +17,11 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
assert.match(
src,
/_h3dFretUniform\s*=\s*localStorage\.getItem\(\s*'highway_3d\.fretSpacing'\s*\)\s*!==\s*'logarithmic'/,
@@ -23,19 +29,25 @@ test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key
);
});
test('fretX switches between the uniform and logarithmic implementations', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
test('fretX is a 1-arg delegator to geoFretX in screen.js (h3d-carve-1)', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
assert.match(
src,
/const\s+fretX\s*=\s*f\s*=>\s*_h3dFretUniform\s*\?\s*_fretXUni\(f\)\s*:\s*_fretXLog\(f\)/,
'fretX must pick _fretXUni when _h3dFretUniform else _fretXLog',
/const\s+fretX\s*=\s*f\s*=>\s*geoFretX\(\s*f\s*,\s*_h3dFretUniform\s*\)/,
'screen.js fretX must delegate to geoFretX(f, _h3dFretUniform) from geometry.js',
);
const geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
assert.match(
geo,
/export\s+const\s+geoFretX\s*=\s*\(\s*f\s*,\s*uniform\s*\)\s*=>/,
'geometry.js must export geoFretX as a 2-arg function (fret, uniform)',
);
});
test('h3dSetFretSpacing validates the mode against the two supported values', () => {
// An unexpected input must not be persisted verbatim — it is coerced to
// one of 'logarithmic' | 'uniform' before writing to localStorage.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
assert.match(
src,
/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?mode\s*===\s*'logarithmic'\s*\?\s*'logarithmic'\s*:\s*'uniform'[\s\S]*?localStorage\.setItem\(\s*'highway_3d\.fretSpacing'/,
@@ -49,7 +61,7 @@ test('h3dSetFretSpacing applies the change live, not via a page reload', () => {
// a 'fretSpacing' change so mounted panels rebuild in place — same path as
// every other 3D-highway setting. Reintroducing location.reload() here is
// the regression this guards against.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
const setter = src.match(/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?\n \};/);
assert.ok(setter, 'h3dSetFretSpacing assignment must be present');
assert.doesNotMatch(
@@ -65,10 +77,11 @@ test('h3dSetFretSpacing applies the change live, not via a page reload', () => {
});
test('the fretSpacing change rebuilds a mounted board live', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
// h3d-carve-16: DI form uses getFretG() getter
assert.match(
src,
/changedKey\s*===\s*'fretSpacing'[\s\S]*?if\s*\(fretG\)\s*buildBoard\(\)/,
/changedKey\s*===\s*'fretSpacing'[\s\S]*?if\s*\((?:fretG|getFretG\(\))\)\s*buildBoard\(\)/,
'the panel bg listener must rebuild the board when fretSpacing changes',
);
});
+374
View File
@@ -0,0 +1,374 @@
// Source + behavioural guards for h3d-carve-8: Q-helpers (lighting/FX utilities)
// extracted to src/fx.js.
//
// Class-killers guaranteed:
// 1. Module exports createFx (source)
// 2. createFx return set covers all 7 required symbols (source)
// 3. Stranded-caller: every returned symbol in screen.js destructure (source)
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
// 5. _timingHex returns EARLY tint when ts='EARLY' and _timingFx truthy (behavioural)
// 6. _sparkBurst writes to getSparkPos() array NOT a stale init-time capture
// (live-accessor class-killer: set new arrays after factory init → RED if stale-cached)
// 7. _sparkUpdate sets needsUpdate=true on pts reached via live getSparkPts()
// (F2 fix: AND assertions; no longer vacuously true via .visible === false)
// 8. _bloomEnsure calls setBloomLoad (synchronous write-back: assignment silenced → RED)
// 8b. _bloomEnsure reads getComposer() live (not init-cached: setComposer after init → RED)
// 9. _applyBloom calls all 4 write-backs (setBloomPass/setBloomW/setBloomH/setComposer)
// (F1 fix: named function testable with mock modules; each sever → RED)
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 FX_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'fx.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function stripComments(s) {
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
}
function src() { return fs.readFileSync(FX_JS, 'utf8'); }
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
// ── 1. Module exports createFx ───────────────────────────────────────────────
test('fx.js exports createFx', () => {
assert.match(src(), /export\s+function\s+createFx\s*\(/);
});
// ── 2. Return set covers all 6 required symbols ──────────────────────────────
test('createFx returns all 6 required symbols', () => {
const stripped = stripComments(src());
const REQUIRED = ['_h3dHexOrDefault', '_applyCinematic', '_timingHex', '_sparkBurst', '_sparkUpdate', '_applyBloom', '_bloomEnsure'];
// Factory-level return block: 4-space indent inside createFx body.
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of REQUIRED) {
assert.ok(returned.includes(sym), `return set must include ${sym}`);
}
});
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
test('every createFx returned symbol appears in screen.js destructure', () => {
const stripped = stripComments(src());
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
const scrRaw = screenSrc();
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createFx\s*\(/);
assert.ok(destrMatch, 'screen.js must have a createFx destructure');
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of returned) {
assert.ok(destructured.includes(sym),
`returned symbol '${sym}' must appear in screen.js createFx destructure`);
}
});
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
test('factory-private symbols in fx.js do not appear bare in screen.js', () => {
const stripped = stripComments(src());
// Collect factory-level return symbols.
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = new Set(retMatch[1].split(',').map(s => s.trim()).filter(Boolean));
// Factory-depth-1: exactly 4-space indented const/let inside createFx.
// (There are none in this module — all vars are per-call locals inside functions.)
const privateSyms = [];
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
const sym = m[1];
if (!returned.has(sym)) privateSyms.push(sym);
}
// Guard: if there ARE any factory-depth-1 privates, they must not be bare in screen.js.
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
scr = stripComments(scr);
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createFx\s*\([^)]*\)\s*;/, '');
const violations = privateSyms.filter(sym => new RegExp('\\b' + sym + '\\b').test(scr));
assert.deepStrictEqual(violations, [],
'screen.js must not reference factory-private fx.js symbols: ' + violations.join(', '));
});
// ── Behavioural vm sandbox ───────────────────────────────────────────────────
function loadFxModule(di) {
const raw = fs.readFileSync(FX_JS, 'utf8');
// Strip ES export keyword so the script runs in a vm context.
const code = raw.replace(/^export\s+function\s+createFx/m, 'function createFx');
// Provide Promise so Promise.all() in _bloomEnsure is defined.
// dynamic import() inside the vm will reject (no module resolution),
// but setBloomLoad() is called BEFORE the rejection fires — it receives
// the pending Promise synchronously, which is what the write-back test checks.
const sandbox = { console, Promise, __exports: {} };
vm.createContext(sandbox);
vm.runInContext(code + '\n__exports.createFx = createFx;', sandbox);
return sandbox.__exports.createFx(di);
}
function makeDi(overrides = {}) {
// Minimal valid DI for behavioural tests.
const state = {
ambLight: null, dirLight: null, _cinematic: false, _timingFx: false,
_sparkPts: null, _SPARK_N: 10,
_sparkPos: new Float32Array(30), _sparkVel: new Float32Array(30),
_sparkCol: new Float32Array(30), _sparkLife: new Float32Array(10),
_composer: null, _bloomLoad: null, _bloomPass: null, _bloomW: 0, _bloomH: 0,
ren: null, scene: null, cam: null, highwayCanvas: null,
T: null,
};
return Object.assign({
BG_DEFAULTS: { nutColor: '#cccccc' },
K: 0.01,
getT: () => state.T, getAmbLight: () => state.ambLight,
getDirLight: () => state.dirLight, getCinematic: () => state._cinematic,
getTimingFx: () => state._timingFx,
getSparkPts: () => state._sparkPts, setSparkPts: (v) => { state._sparkPts = v; },
getSparkN: () => state._SPARK_N,
getSparkPos: () => state._sparkPos, setSparkPos: (v) => { state._sparkPos = v; },
getSparkVel: () => state._sparkVel, setSparkVel: (v) => { state._sparkVel = v; },
getSparkCol: () => state._sparkCol, setSparkCol: (v) => { state._sparkCol = v; },
getSparkLife: () => state._sparkLife, setSparkLife: (v) => { state._sparkLife = v; },
getComposer: () => state._composer, setComposer: (v) => { state._composer = v; },
getBloomLoad: () => state._bloomLoad, setBloomLoad: (v) => { state._bloomLoad = v; },
getBloomPass: () => state._bloomPass, setBloomPass: (v) => { state._bloomPass = v; },
getBloomW: () => state._bloomW, setBloomW: (v) => { state._bloomW = v; },
getBloomH: () => state._bloomH, setBloomH: (v) => { state._bloomH = v; },
getRen: () => state.ren, getScene: () => state.scene,
getCam: () => state.cam, getHighwayCanvas: () => state.highwayCanvas,
canvasSize: () => ({ w: 800, h: 600 }),
_state: state,
}, overrides);
}
// ── 5. _timingHex returns EARLY tint when ts='EARLY' and timingFx truthy ─────
test('_timingHex returns EARLY hex when ts=EARLY and timingFx is truthy', () => {
// Mutation that goes RED: remove the EARLY branch → returns 0x22ff88 instead.
const di = makeDi();
di._state._timingFx = true;
const { _timingHex } = loadFxModule(di);
assert.equal(_timingHex('EARLY'), 0x35d6ff, 'EARLY timing must return cyan 0x35d6ff');
assert.equal(_timingHex('LATE'), 0xffb84d, 'LATE timing must return amber 0xffb84d');
assert.equal(_timingHex('OK'), 0x22ff88, 'OK timing must return green');
});
// ── 6. _sparkBurst live-accessor class-killer ─────────────────────────────────
test('_sparkBurst writes to the sparkPos array returned by getSparkPos (live, not init-cached)', () => {
// Mutation that goes RED: if _sparkBurst caches `const _sparkPos = getSparkPos()` at
// factory init time instead of per-call, then calling setSparkPos(newArray) after init
// and triggering a burst will write to the STALE array → newArray stays all zeros → RED.
const di = makeDi();
// Bootstrap: provide a sparkPts stub so _sparkBurst doesn't bail early.
di._state._sparkPts = { geometry: { attributes: { position: { needsUpdate: false }, color: { needsUpdate: false } } }, visible: false };
// Set a dead particle slot so _sparkBurst can spawn into it.
di._state._sparkLife[0] = 0;
const { _sparkBurst } = loadFxModule(di);
// Burst writes into initial array.
_sparkBurst(1, 2, 3, 0xff0000, 1);
const firstArr = di._state._sparkPos;
// At least x position should be set (= 1).
assert.equal(firstArr[0], 1, 'sparkPos[0] should be x=1 after burst into initial array');
// Now rebuild: replace sparkPos with a fresh zero array.
const newPos = new Float32Array(30);
di.setSparkPos(newPos);
// Reset life for slot 0 so burst fires again.
di._state._sparkLife[0] = 0;
_sparkBurst(5, 6, 7, 0x00ff00, 1);
assert.equal(newPos[0], 5,
'_sparkBurst must write into the NEW sparkPos after setSparkPos rebuild; ' +
'if 0 it cached the initial array at factory init time (live-accessor broken)');
});
// ── 7. _sparkUpdate live-accessor class-killer (F2 fix) ───────────────────────
test('_sparkUpdate sets needsUpdate=true on pts reached via live getSparkPts()', () => {
// Mutation that goes RED: if getSparkPts() result is cached at factory init,
// setSparkPts(newPts) after init → _sparkUpdate still references stale (null) pts
// → needsUpdate flags never set → BOTH assertions fail → RED.
//
// F2 fix: previous test used `|| pts.visible === false` which is vacuously true
// (no living sparks → visible stays false) — a NO-OP _sparkUpdate passed the test.
// Now we assert needsUpdate=true (set unconditionally after the loop) via AND.
const di = makeDi();
const { _sparkUpdate } = loadFxModule(di);
// No sparkPts yet — short-circuit (must not throw).
_sparkUpdate(0.016);
// Now provide sparkPts (simulates buildBoard completing after factory init).
const pts = {
geometry: { attributes: {
position: { needsUpdate: false },
color: { needsUpdate: false },
}},
visible: true,
};
di.setSparkPts(pts);
_sparkUpdate(0.016);
// _sparkUpdate sets needsUpdate unconditionally after the particle loop.
// If _sparkUpdate cached the stale null ptr at factory init, both stay false.
assert.ok(pts.geometry.attributes.position.needsUpdate === true,
'_sparkUpdate must set position.needsUpdate=true (reached via live getSparkPts)');
assert.ok(pts.geometry.attributes.color.needsUpdate === true,
'_sparkUpdate must set color.needsUpdate=true (reached via live getSparkPts)');
});
// ── 8. _bloomEnsure write-back class-killer: setBloomLoad called ──────────────
test('_bloomEnsure calls setBloomLoad when ren/scene/cam are available', () => {
// Mutation that goes RED: if _bloomEnsure does `const bl = Promise.all(...)` (local)
// instead of `setBloomLoad(Promise.all(...))`, getBloomLoad() stays null after the
// call → every subsequent frame re-enters init → duplicate composers → visual glitch.
// This is the synchronously verifiable half of the write-back contract.
const di = makeDi();
// Provide non-null ren/scene/cam so the guard passes.
di._state.ren = {}; di._state.scene = {}; di._state.cam = {};
di._state.T = { WebGLRenderTarget() {}, HalfFloatType: 1, Vector2() {} };
const { _bloomEnsure } = loadFxModule(di);
const result = _bloomEnsure();
assert.equal(result, null, '_bloomEnsure returns null on first call (async init started)');
assert.ok(di.getBloomLoad() instanceof Promise,
'setBloomLoad must have been called with the init Promise; ' +
'if getBloomLoad() is null the assignment silently became a local (write-back broken)');
// A second call must short-circuit on the existing bloomLoad (not start a second init).
const result2 = _bloomEnsure();
assert.equal(result2, null, 'second call must return null (init still in flight, not re-started)');
});
// ── 8b. _bloomEnsure getComposer live-accessor ────────────────────────────────
test('_bloomEnsure returns composer set via setComposer (reads live via getComposer)', () => {
// Mutation that goes RED: if _bloomEnsure caches `const _composer = getComposer()`
// at factory init time, a later setComposer(comp) is invisible → returns null forever.
const di = makeDi();
const { _bloomEnsure } = loadFxModule(di);
// Initially null.
assert.equal(_bloomEnsure(), null, 'must return null when composer not yet set');
// Simulate bloom async chain resolving.
const fakeComp = { render() {}, setSize() {} };
di.setComposer(fakeComp);
// Now must return the composer (reads via live getComposer(), not init-cached).
assert.equal(_bloomEnsure(), fakeComp,
'_bloomEnsure must return composer set via setComposer; ' +
'null means it cached the initial null value at factory init time');
});
// ── 9. _applyBloom calls all 4 write-backs (F1 fix) ──────────────────────────
test('_applyBloom calls setBloomPass/setBloomW/setBloomH/setComposer with mock modules', () => {
// Mutation scenarios (all 4 must go RED when severed individually):
// sever setBloomPass(bp) → getBloomPass() stays null → RED
// sever setBloomW(w) → getBloomW() stays 0 → RED
// sever setBloomH(h) → getBloomH() stays 0 → RED
// sever setComposer(comp)→ getComposer() stays null → RED
//
// F1 Toby fix: _applyBloom is named at factory scope and in the return set,
// so the harness calls it directly with mock [EC, RP, UB, OP] — no import() needed.
const di = makeDi();
di._state.T = {
WebGLRenderTarget: function(w, h, opts) { return { _w: w, _h: h }; },
HalfFloatType: 1,
Vector2: function(w, h) { return { w, h }; },
};
di._state.ren = { isRenderer: true };
di._state.scene = { isScene: true };
di._state.cam = { isCamera: true };
const { _applyBloom } = loadFxModule(di);
// Mock module objects matching the destructure [EC, RP, UB, OP].
const fakeComp = { addPass() {}, setSize() {} };
const fakePass = { isBloomPass: true };
const mods = [
{ EffectComposer: function(ren, rt) { return fakeComp; } },
{ RenderPass: function(scene, cam) { return {}; } },
{ UnrealBloomPass: function(v2, s, r, t) { return fakePass; } },
{ OutputPass: function() { return {}; } },
];
_applyBloom(mods);
assert.equal(di.getComposer(), fakeComp,
'setComposer must be called: if severed, getComposer() stays null → bloom never activates');
assert.equal(di.getBloomPass(), fakePass,
'setBloomPass must be called: if severed, pass ref lost → resize/tuning broken');
assert.ok(di.getBloomW() > 0,
'setBloomW must be called with positive width');
assert.ok(di.getBloomH() > 0,
'setBloomH must be called with positive height');
});
// ── 10. _applyCinematic discriminating test (Creed gap fix) ──────────────────
test('_applyCinematic sets light intensities per _cinematic flag (both paths)', () => {
// Mutation that goes RED: `return` inserted at _applyCinematic entry
// → intensities never change → all 4 assertions fail → RED.
const di = makeDi();
const ambLight = { intensity: 0 };
const dirLight = { intensity: 0 };
di._state.ambLight = ambLight;
di._state.dirLight = dirLight;
const { _applyCinematic } = loadFxModule(di);
// Cinematic ON: darken ambient, strengthen key light.
di._state._cinematic = true;
_applyCinematic();
assert.equal(ambLight.intensity, 0.45,
'cinematic=true: ambLight.intensity must be 0.45 (darken for emissive pop)');
assert.equal(dirLight.intensity, 1.15,
'cinematic=true: dirLight.intensity must be 1.15 (stronger key)');
// Cinematic OFF: standard balanced lighting.
di._state._cinematic = false;
_applyCinematic();
assert.equal(ambLight.intensity, 0.85,
'cinematic=false: ambLight.intensity must be 0.85 (standard ambient)');
assert.equal(dirLight.intensity, 0.8,
'cinematic=false: dirLight.intensity must be 0.8 (standard key)');
});
// ── 11. _h3dHexOrDefault — source-scan fixture + literal-pin (Creed r2 fix) ──
test('_h3dHexOrDefault parses valid hex and falls back to BG_DEFAULTS (source-scan fixture)', () => {
// Source-scan: extract the REAL BG_DEFAULTS.nutColor from screen.js so the
// fixture uses the production default, not an invented value.
//
// Guard assertions fail if BG_DEFAULTS is removed or restructured in screen.js —
// drift becomes loud, not silent.
const scr = screenSrc();
const bgDefMatch = scr.match(/const BG_DEFAULTS\s*=\s*\{[^}]+\}/);
assert.ok(bgDefMatch, 'BG_DEFAULTS object literal must be present in screen.js');
const nutColorMatch = bgDefMatch[0].match(/nutColor:\s*'([^']+)'/);
assert.ok(nutColorMatch, 'BG_DEFAULTS.nutColor key must be extractable from screen.js source');
const PROD_NUT_COLOR = nutColorMatch[1];
// Literal-pin: if production nutColor drifts this assertion fails loudly.
// To update intentionally: change the pinned value below to match the new production value.
assert.equal(PROD_NUT_COLOR, '#f5f3f0',
'BG_DEFAULTS.nutColor in screen.js has changed — update this pin if the change is intentional');
// Build fixture using the real production nutColor (not an invented '#cccccc').
// Mutation that goes RED: return BG_DEFAULTS.nutColor unconditionally
// → valid-hex assertion returns fallback instead of parsed value → RED.
const di = makeDi({ BG_DEFAULTS: { nutColor: PROD_NUT_COLOR } });
const { _h3dHexOrDefault } = loadFxModule(di);
// Valid 6-digit hex with # → parsed integer.
assert.equal(_h3dHexOrDefault('#a1b2c3', null), 0xa1b2c3,
'valid hex string must be parsed to its integer value');
// Hex without # → regex requires #, falls back to BG_DEFAULTS.nutColor.
assert.equal(_h3dHexOrDefault('a1b2c3', null), parseInt(PROD_NUT_COLOR.slice(1), 16),
'hex without # must fall back to BG_DEFAULTS.nutColor (regex requires leading #)');
// Gibberish → BG_DEFAULTS fallback.
assert.equal(_h3dHexOrDefault('not-a-color', null), parseInt(PROD_NUT_COLOR.slice(1), 16),
'invalid string must fall back to BG_DEFAULTS.nutColor');
// Explicit defHex overrides BG_DEFAULTS.
assert.equal(_h3dHexOrDefault('not-a-color', '#ffffff'), 0xffffff,
'invalid string with explicit defHex must use defHex, not BG_DEFAULTS');
});
+178
View File
@@ -0,0 +1,178 @@
// Class-killer for src/geometry.js — h3d-carve-1.
//
// Uses dynamic import() (not the vm source-scan pattern) so Node actually
// evaluates the ES module and its exports are the real runtime values.
// A refactor that renames geoFretX, changes the uniform/logarithmic
// decision, removes slideTrailEnd, or breaks computeBPM's BPM estimate
// would be caught here before any other test.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
test('geoFretX returns 0 for fret 0 in both modes', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
assert.strictEqual(geoFretX(0, true), 0, 'uniform: fret 0 must be 0');
assert.strictEqual(geoFretX(0, false), 0, 'logarithmic: fret 0 must be 0');
});
test('geoFretX uniform spacing is linear — fret N is N × fret 1', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
const step = geoFretX(1, true);
assert.ok(step > 0, 'uniform step must be positive');
assert.ok(Math.abs(geoFretX(5, true) - 5 * step) < 1e-9, 'fret 5 must be 5 × step');
assert.ok(Math.abs(geoFretX(12, true) - 12 * step) < 1e-9, 'fret 12 must be 12 × step');
});
test('geoFretX logarithmic spacing is non-linear — frets compress toward the bridge', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
const d1 = geoFretX(1, false);
const d2 = geoFretX(2, false) - geoFretX(1, false);
const d3 = geoFretX(3, false) - geoFretX(2, false);
assert.ok(d1 > d2, 'fret 1 gap must be wider than fret 2 gap (compression toward bridge)');
assert.ok(d2 > d3, 'fret 2 gap must be wider than fret 3 gap');
});
test('geoFretX uniform and logarithmic agree at fret 24 (total board width)', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
// By construction: _fretXUniStep = _fretXLog(24) / 24, so geoFretX(24, uniform)
// equals geoFretX(24, logarithmic). This is the board-width invariant.
const uniWidth = geoFretX(24, true);
const logWidth = geoFretX(24, false);
assert.ok(Math.abs(uniWidth - logWidth) < 1e-9, 'board width must be identical in both modes');
});
test('dZ converts positive dt to a negative Z delta', async () => {
const { dZ } = await import(GEOMETRY_JS);
assert.ok(dZ(1) < 0, 'positive time delta must produce negative Z (notes travel toward camera)');
assert.ok(dZ(0) === 0, 'zero dt must produce zero dZ');
assert.ok(Math.abs(dZ(2) / dZ(1) - 2) < 1e-9, 'dZ must be linear in dt');
});
test('slideTrailEnd returns null for notes with no slide fields', async () => {
const { slideTrailEnd } = await import(GEOMETRY_JS);
assert.strictEqual(slideTrailEnd({}), null);
assert.strictEqual(slideTrailEnd({ sl: -1 }), null, 'negative sl must be ignored');
});
test('slideTrailEnd prefers sl over slu and marks pitched/unpitched correctly', async () => {
const { slideTrailEnd } = await import(GEOMETRY_JS);
assert.deepStrictEqual(slideTrailEnd({ sl: 7 }), { endFret: 7, unpitched: false });
assert.deepStrictEqual(slideTrailEnd({ slu: 5 }), { endFret: 5, unpitched: true });
assert.deepStrictEqual(slideTrailEnd({ sl: 7, slu: 5 }), { endFret: 7, unpitched: false });
});
test('computeBPM returns 120 for degenerate inputs', async () => {
const { computeBPM } = await import(GEOMETRY_JS);
assert.strictEqual(computeBPM(null, 0), 120);
assert.strictEqual(computeBPM([], 0), 120);
assert.strictEqual(computeBPM([{ time: 0 }], 0), 120, 'single beat has no interval');
});
test('computeBPM estimates 120 BPM from evenly-spaced beats', async () => {
const { computeBPM } = await import(GEOMETRY_JS);
// 120 BPM = 0.5 s per beat
const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time }));
const bpm = computeBPM(beats, 1.0);
assert.ok(Math.abs(bpm - 120) < 0.01, `expected ~120 BPM, got ${bpm}`);
});
// Toby r1 findings: camBaseDistU, camLowFretPullbackU, _makeGaussTex had no
// class-killer tests. Each test below names the concrete mutation it catches.
test('camBaseDistU clamps span to minimum 4 — span=0 gives 77 not 65', async () => {
// Mutation: Math.max(span,4) → span
// camBaseDistU(0) mutant = 65+0*3 = 65 (wrong); original = 65+4*3 = 77
const { camBaseDistU } = await import(GEOMETRY_JS);
assert.strictEqual(camBaseDistU(0), 77, 'span=0: floor=4 so 65+4*3=77, not 65');
assert.strictEqual(camBaseDistU(10), 95, 'span=10: 65+10*3=95');
});
test('camLowFretPullbackU is clamped to zero — high fret gives 0 not negative', async () => {
// Mutation: drop Math.max(0,...) clamp
// camLowFretPullbackU(10) mutant = (5-10)*4 = -20 (wrong); original = 0
const { camLowFretPullbackU } = await import(GEOMETRY_JS);
assert.strictEqual(camLowFretPullbackU(0), 20, 'fret 0: (5-0)*4=20');
assert.strictEqual(camLowFretPullbackU(5), 0, 'fret 5: (5-5)*4=0');
assert.strictEqual(camLowFretPullbackU(10), 0, 'fret 10: clamped to 0, not -20');
});
// ── Cut 1b class-killers ───────────────────────────────────────────────────────
test('RENDER_ORDER_LAYER_STACK has 17 layers with CHORD_FILL first and CHORD_FRET_LABEL last', async () => {
const { RENDER_ORDER_LAYER_STACK } = await import(GEOMETRY_JS);
assert.strictEqual(RENDER_ORDER_LAYER_STACK.length, 17, 'stack must have exactly 17 layers');
assert.strictEqual(RENDER_ORDER_LAYER_STACK[0], 'CHORD_FILL', 'first layer must be CHORD_FILL');
assert.strictEqual(RENDER_ORDER_LAYER_STACK[RENDER_ORDER_LAYER_STACK.length - 1], 'CHORD_FRET_LABEL', 'last layer must be CHORD_FRET_LABEL');
});
test('RENDER_ORDER_LAYER_INDEX maps CHORD_FILL to 0 and NOTE_CORE to 10', async () => {
// Mutation: wrong layer order → NOTE_CORE would not map to 10.
const { RENDER_ORDER_LAYER_INDEX } = await import(GEOMETRY_JS);
assert.strictEqual(RENDER_ORDER_LAYER_INDEX['CHORD_FILL'], 0, 'CHORD_FILL must be index 0 (bottom of stack)');
assert.strictEqual(RENDER_ORDER_LAYER_INDEX['NOTE_CORE'], 10, 'NOTE_CORE must be index 10');
});
test('renderOrderForLayerAtZ applies the far clamp — worldZ=-5 gives 50 not 33', async () => {
// Mutation: remove Math.max(RENDER_ORDER_FAR_CLAMP, ...) clamp.
// K=2.25/300=0.0075; Math.round(700+(-5)/0.0075)=Math.round(33.33)=33; max(50,33)=50.
// Without clamp: 33 + 0/17 ≈ 33. Test pins the clamped value.
const { renderOrderForLayerAtZ } = await import(GEOMETRY_JS);
assert.strictEqual(renderOrderForLayerAtZ(-5, 'CHORD_FILL'), 50, 'far objects must be clamped to RENDER_ORDER_FAR_CLAMP=50');
});
test('renderOrderForLayerAtZ throws for unknown layer names', async () => {
const { renderOrderForLayerAtZ } = await import(GEOMETRY_JS);
assert.throws(() => renderOrderForLayerAtZ(0, 'NONEXISTENT'), /Unknown 3D highway depth layer/);
});
test('_noteKey integer-truncates float times — _noteKey(1.5, 3) is 150003 not 150008', async () => {
// Mutation: drop |0 → (15000.5)*10+3 = 150008.
const { _noteKey } = await import(GEOMETRY_JS);
assert.strictEqual(_noteKey(1.5, 3), 150003, '|0 truncation must give 150003, not float-derived 150008');
assert.strictEqual(_noteKey(0, 0), 0);
});
test('lowerBoundT returns first index where arr[i].t >= t (strict lower bound)', async () => {
// Mutation: < → <= causes lowerBoundT([{t:1},{t:3},{t:5}], 3) → 2 instead of 1.
const { lowerBoundT } = await import(GEOMETRY_JS);
const arr = [{ t: 1 }, { t: 3 }, { t: 5 }];
assert.strictEqual(lowerBoundT(arr, 3), 1, 'strict lower-bound: first index where .t >= 3 is 1 (not 2)');
assert.strictEqual(lowerBoundT(arr, 0), 0, 'value before all: must return 0');
assert.strictEqual(lowerBoundT(arr, 6), 3, 'value after all: must return length');
});
test('hwyFirstRelevantFrettedTime returns null for empty/all-open input', async () => {
const { hwyFirstRelevantFrettedTime } = await import(GEOMETRY_JS);
assert.strictEqual(hwyFirstRelevantFrettedTime([], [], 0, 0.2, 6), null);
});
test('geoFretMid returns -2K sentinel for f<=0, positive for f=1', async () => {
// Mutation: drop f<=0 guard → geoFretMid(0, true) returns (0+0)/2=0, not -0.015.
const { geoFretMid } = await import(GEOMETRY_JS);
const K = 2.25 / 300;
assert.ok(Math.abs(geoFretMid(0, true) - (-2 * K)) < 1e-10, 'f=0 must return -2K sentinel (≈-0.015)');
assert.ok(geoFretMid(1, true) > 0, 'f=1 must return positive X');
// In uniform mode: geoFretX(0,true)=0, geoFretX(1,true)=step, so mid=step/2.
// geoFretMid(2,true) = (step+2step)/2 = 1.5step. Ratio 3 catches wrong f offset.
assert.ok(Math.abs(geoFretMid(2, true) / geoFretMid(1, true) - 3) < 1e-9, 'uniform mid(2)/mid(1) must equal 3');
});
test('_makeGaussTex peak alpha is 255 at the centre pixel', async () => {
// Mutation: default sigma changed to 0 → (u-0.5)/0 = NaN chain → all Uint8Array writes
// become 0 (TypedArray coerces NaN to 0). Test calls without explicit sigma so the
// default is exercised directly — changing the default is what is being guarded.
// Use odd width=3: i=1 gives u=0.5 exactly (d=(u-0.5)/sigma=0, peak=1, alpha=255).
const { _makeGaussTex } = await import(GEOMETRY_JS);
let capturedData;
const ThreeStub = {
DataTexture: class { constructor(d) { capturedData = d; } },
RGBAFormat: 1,
LinearFilter: 2,
};
_makeGaussTex(ThreeStub, 3); // no sigma arg — exercises the default (0.28)
// Pixel i=1: RGBA layout [4,5,6,7]; alpha is at index 7
assert.strictEqual(capturedData[7], 255, 'centre pixel (i=1 of w=3) alpha must be 255 at default sigma');
});
+17 -8
View File
@@ -19,6 +19,12 @@ const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-14: sustain trail code moved to note-renderer.js; trail tests
// must now search both files.
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
const _noteRendererSrc = fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
const _rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
test('lean sustain rendering is the default (_leanSus starts true)', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
@@ -30,34 +36,37 @@ test('lean sustain rendering is the default (_leanSus starts true)', () => {
});
test('the full-quality look is an opt-out via localStorage h3d_full_sus', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-15: lean poll + setLeanSus call now in renderer.js update()
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
assert.match(
src,
/_leanSus\s*=\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]/,
/setLeanSus\s*\(\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]\s*\)/,
"lean must stay on unless localStorage.h3d_full_sus === '1' opts back into the full look",
);
});
test('exactly one element is gated behind the lean flag, and it is the rail bloom', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-15: lean gate moved to renderer.js; getter form getLeanSus()
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// Only the additive rail bloom may hide behind the lean flag. If a future
// edit re-gates the trail or ribbon outline behind !_leanSus, this count
// edit re-gates the trail or ribbon outline behind !getLeanSus(), this count
// climbs above 1 and the test fails — that's the regression guard.
const gates = src.match(/if\s*\(\s*!_leanSus\s*\)/g) || [];
const gates = src.match(/if\s*\(\s*!getLeanSus\s*\(\s*\)\s*\)/g) || [];
assert.equal(
gates.length,
1,
'expected exactly one `if (!_leanSus)` gate (the rail bloom); the outline must stay ungated',
'expected exactly one `if (!getLeanSus())` gate (the rail bloom); the outline must stay ungated',
);
assert.match(
src,
/if\s*\(\s*!_leanSus\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
/if\s*\(\s*!getLeanSus\s*\(\s*\)\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
'the single lean gate must be the one that wraps pSusRailBloom.get()',
);
});
test('the trail + ribbon outline always draw and use the hit/miss-aware material', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-14: sustain trail body now in note-renderer.js
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteRendererSrc;
// Outline material is hit/miss aware: miss -> mMissOutline, confirmed hit
// -> bright, otherwise the default mSusOutline white border.
assert.match(
+7 -3
View File
@@ -12,6 +12,8 @@ const ROOT = path.join(__dirname, '..', '..');
const HIGHWAY_JS = path.join(ROOT, 'static', 'highway.js');
const SCREEN_JS = path.join(ROOT, 'plugins', 'highway_3d', 'screen.js');
const CLAUDE_MD = path.join(ROOT, 'plugins', 'highway_3d', 'CLAUDE.md');
// h3d-carve-9: camUpdate (including shoulderOffset + _camX) moved to camera.js.
const CAMERA_JS = path.join(ROOT, 'plugins', 'highway_3d', 'src', 'camera.js');
function src(file) {
return fs.readFileSync(file, 'utf8');
@@ -69,12 +71,14 @@ test('draw(bundle) handles lefty changes by flipping camera X state and rebuildi
});
test('camera shoulder offset follows the cached lefty orientation', () => {
// h3d-carve-9: camUpdate (shoulderOffset + _camX) moved to src/camera.js;
// _leftyCached is DI-rewired to getLeftyCached() direct call.
assert.match(
src(SCREEN_JS),
src(CAMERA_JS),
// The shoulder offset now feeds the base _camX (which the opt-in
// free-camera bridge layers on top of) before cam.position.set (#771).
/const\s+shoulderOffset\s*=\s*\(\s*_leftyCached\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
'camera shoulder offset must flip with _leftyCached',
/const\s+shoulderOffset\s*=\s*\(\s*getLeftyCached\(\)\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
'camera shoulder offset must flip with getLeftyCached()',
);
});
+361
View File
@@ -0,0 +1,361 @@
// Contract tests for h3d-carve-6: src/materials.js (createMaterialBuilders).
//
// Class-killer tests — each names the mutation that makes it RED.
// Source-scan + vm-sandbox pattern (no canvas, no WebGL lifecycle).
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 MATERIALS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'materials.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function src() { return fs.readFileSync(MATERIALS_JS, 'utf8'); }
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
// Strip block and line comments from JS source for identifier-presence checks.
function stripComments(s) {
return s
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/[^\n]*/g, ''); // line comments
}
// ── vm sandbox helpers ──────────────────────────────────────────────────────
// Evaluate materials.js in a sandbox and return the createMaterialBuilders export.
// Three.js is stubbed; canvas ops are no-ops.
function loadFactory() {
const raw = src();
// materials.js uses `export function` — strip the `export` keyword for vm.
const code = raw.replace(/^export\s+/m, '');
const sandbox = {
document: {
createElement: () => ({
getContext: () => ({
font: '', textAlign: '', textBaseline: '',
clearRect() {}, beginPath() {}, moveTo() {}, lineTo() {},
closePath() {}, fill() {}, stroke() {}, fillText() {},
strokeText() {}, save() {}, restore() {}, translate() {},
ellipse() {}, arc() {}, createRadialGradient: () => ({
addColorStop() {},
}),
measureText: () => ({
width: 10,
actualBoundingBoxLeft: 0, actualBoundingBoxRight: 10,
actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2,
}),
getImageData: (x, y, w, h) => ({ data: new Uint8Array(w * h * 4) }),
fillStyle: '', strokeStyle: '', lineWidth: 0,
lineJoin: '', lineCap: '', shadowColor: '',
shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
miterLimit: 0, globalCompositeOperation: '',
}),
width: 0, height: 0,
}),
},
Math,
Number,
Map,
Set,
String,
Object,
Array,
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: MATERIALS_JS });
return sandbox.createMaterialBuilders;
}
// Build a minimal DI bundle for testing factory internals.
function makeDI(overrides = {}) {
let txtCache = {};
const techMatCache = new Map();
const techMeshMatClones = new Set();
const T = {
SpriteMaterial: class { constructor(o) { Object.assign(this, o); this.map = o.map; this.userData = {}; } clone() { const c = new T.SpriteMaterial(this); return c; } dispose() {} },
MeshBasicMaterial: class { constructor(o) { Object.assign(this, o); this.userData = {}; } clone() { return new T.MeshBasicMaterial(this); } dispose() {} },
CanvasTexture: class { constructor(c) { this._c = c; } dispose() {} },
Color: class { constructor(v) { this.r = 1; this.g = 1; this.b = 1; } getHexString() { return 'ffffff'; } },
DoubleSide: 2,
};
return {
getT: () => T,
getTxtCache: () => txtCache,
techMatCache,
techMeshMatClones,
_resetCache: () => { txtCache = {}; },
...overrides,
};
}
// ── 1. Module exports createMaterialBuilders ────────────────────────────────
test('src/materials.js exports createMaterialBuilders', () => {
// Mutation: rename to createMaterials → RED.
assert.match(src(), /export\s+function\s+createMaterialBuilders\s*\(/, 'must export createMaterialBuilders');
});
// ── 2. Return set covers all expected symbols ───────────────────────────────
test('createMaterialBuilders returns all required symbols', () => {
// Mutation: remove `pool` from return {...} → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const result = createMaterialBuilders(di);
const EXPECTED = [
'txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat',
'palmMuteXSpriteMat', 'fretHandMuteXSpriteMat', 'muteXMat',
'triMat', 'bendChevronMat', 'darkenHex', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat', 'pool',
];
for (const sym of EXPECTED) {
assert.ok(sym in result, `createMaterialBuilders must return ${sym}`);
}
});
// ── 3. Stranded-caller: every returned symbol in screen.js destructure ───────
test('every symbol returned by createMaterialBuilders is in the screen.js destructure', () => {
// Mutation: add _newHelper to return{} but not the screen.js destructure → leaked=['_newHelper'] → RED.
const matSrc = stripComments(src());
const scr = screenSrc();
// Extract the module-level return block — anchored by the first symbol
// (txtMat) so pool's inner return { get, reset, warm } can't shadow it.
// Mutation: remove txtMat from the return → anchor fails → RED.
const retMatch = matSrc.match(/return\s*\{\s*\n\s*(txtMat\s*,[\s\S]+?)\n\s*\};/);
assert.ok(retMatch, 'createMaterialBuilders must end with return { txtMat, ... }');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Extract destructured names from the screen.js tombstone.
const dsMatch = scr.match(/const\s*\{([^}]+)\}\s*=\s*createMaterialBuilders\s*\(/);
assert.ok(dsMatch, 'screen.js must have createMaterialBuilders destructure');
const destructured = new Set(
dsMatch[1].split(',').map(s => s.trim().split(/\s+/).pop()).filter(Boolean)
);
const leaked = [...returned].filter(sym => !destructured.has(sym));
assert.deepStrictEqual(leaked, [],
'createMaterialBuilders returns symbols not in screen.js destructure: ' + leaked.join(', '));
});
// ── 4. Stale-private guard: factory-private symbols not bare in screen.js ──────
test('factory-private symbols in materials.js do not appear bare in screen.js', () => {
// Mutation: add bare TXT_STYLES to screen.js body → violations=['TXT_STYLES'] → RED.
// Kills the whole class: any factory-depth-1 const/let NOT in the return set
// must not leak into screen.js. Catches TXT_STYLES, _pmXSpriteMat, _fhXSpriteMat
// and any future factory-private additions automatically.
const matSrc = stripComments(src());
const scrRaw = screenSrc();
// Extract the returned symbol set (reuse test 3's anchor).
const retMatch = matSrc.match(/return\s*\{\s*\n\s*(txtMat\s*,[\s\S]+?)\n\s*\};/);
assert.ok(retMatch, 'return block must be present');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Factory-depth-1 const/let declarations: exactly 4-space indent.
// These are factory-private vars (_pmXSpriteMat, _fhXSpriteMat, TXT_STYLES …).
// Depth-2 locals (const T = getT() etc.) have 8+ spaces — excluded by anchor.
const privateSyms = [];
for (const m of matSrc.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
const sym = m[1];
if (!returned.has(sym)) privateSyms.push(sym);
}
assert.ok(privateSyms.length > 0, 'factory must have at least one private depth-1 declaration');
// Strip screen.js of import lines, comments, and the destructure line.
let scr = scrRaw.replace(/^import\s+.*\n/gm, '');
scr = stripComments(scr);
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createMaterialBuilders\s*\([^)]*\)\s*;/, '');
const violations = privateSyms.filter(sym =>
new RegExp('\\b' + sym + '\\b').test(scr)
);
assert.deepStrictEqual(violations, [],
'screen.js must not reference factory-private materials.js symbols: ' + violations.join(', '));
});
// ── 5. DI: T is accessed via getT() at call time, not factory construction ───
test('material builder functions call getT() at call time', () => {
// Mutation: top-level `const T = getT()` at factory construction → RED.
// Each function must call getT() inside its own body.
const s = src();
// Must NOT have `const T = getT()` at the top level of the factory
// (outside any function body). Check that it's scoped inside function bodies.
assert.doesNotMatch(
s,
/createMaterialBuilders\s*\([^)]*\)\s*\{[^}]*const T = getT\(\)/,
'T must not be captured at factory construction — only inside function bodies'
);
// Each T-using function must contain getT() in its body.
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat',
'triMat', 'bendChevronMat', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat']) {
const fnIdx = s.indexOf(`function ${fn}(`);
assert.ok(fnIdx !== -1, `${fn} must exist in materials.js`);
// Find the body of this function (brace-balanced).
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /const T = getT\(\)/, `${fn} must call getT() inside its body`);
}
});
// ── 6. DI: txtCache is accessed via getTxtCache() inside each function ────────
test('txtCache-using functions access cache via getTxtCache()', () => {
// Mutation: use bare `txtCache[k]` instead → RED (and also a runtime bug).
const s = stripComments(src());
// The module code must never reference a bare `txtCache` identifier.
assert.doesNotMatch(s, /\btxtCache\b/, 'materials.js code must not reference bare txtCache — use getTxtCache()');
// Each cache-using function must call getTxtCache().
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat']) {
assert.ok(s.includes(`function ${fn}(`), `${fn} must exist`);
const fnIdx = s.indexOf(`function ${fn}(`);
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /getTxtCache\(\)/, `${fn} must call getTxtCache() inside its body`);
}
});
// ── 7. DI: techMatCache param used (not bare _techMatCache) ──────────────────
test('triMat/bendChevronMat/slideArrowMat use techMatCache DI param', () => {
// Mutation: use `_techMatCache.get(key)` → RED (and runtime ReferenceError).
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMatCache\b/, 'materials.js code must not reference _techMatCache — use DI param techMatCache');
});
// ── 8. DI: techMeshMatClones param used (not bare _techMeshMatClones) ─────────
test('_spriteMat2MeshMat uses techMeshMatClones DI param', () => {
// Mutation: use `_techMeshMatClones.add(clone)` → RED.
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMeshMatClones\b/, 'materials.js code must not reference _techMeshMatClones — use DI param');
});
// ── 9. TXT_STYLES literal pin ────────────────────────────────────────────────
test('TXT_STYLES presets match known-good values', () => {
// Mutation: change technique.srcH from 128 to 256 → RED.
const s = src();
// fretRow / noteFret / ghostFret — srcH 256, strokeW 18
for (const key of ['fretRow', 'noteFret', 'ghostFret']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*256'), `${key}.srcH must be 256`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*18'), `${key}.strokeW must be 18`);
}
// All three large presets share this stroke color.
assert.ok(s.includes("stroke: '#0a1018'"), "fretRow/noteFret/ghostFret stroke must be '#0a1018'");
// chord / section / technique / open — srcH 128, strokeW 6
for (const key of ['chord', 'section', 'technique', 'open']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*128'), `${key}.srcH must be 128`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*6'), `${key}.strokeW must be 6`);
}
});
// ── 10. darkenHex is pure (no T dependency) ──────────────────────────────────
test('darkenHex has no getT() call', () => {
// Mutation: add getT() call → RED (no T needed for a pure bit-twiddler).
const s = src();
const fnIdx = s.indexOf('function darkenHex(');
assert.ok(fnIdx !== -1, 'darkenHex must exist');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'darkenHex must not call getT() — it is a pure bit-twiddler');
});
// ── 11. pool has no getT() call ──────────────────────────────────────────────
test('pool has no getT() call', () => {
// Mutation: add getT() call → RED (pool creates no Three.js objects).
const s = src();
const fnIdx = s.indexOf('function pool(parent, mk)');
assert.ok(fnIdx !== -1, 'pool must exist in materials.js');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'pool must not call getT() — it is a pure container factory');
});
// ── 12. screen.js tombstone is present ───────────────────────────────────────
test('screen.js has the h3d-carve-6 tombstone comment', () => {
// Mutation: delete the tombstone block → RED.
assert.match(screenSrc(), /h3d-carve-6: material builders/, 'tombstone comment must be present');
assert.match(screenSrc(), /const _techMatCache = new Map\(\)/, '_techMatCache must be hoisted to screen.js factory scope');
});
// ── 13. _techMatCache NOT declared in materials.js code ───────────────────────
test('_techMatCache is not declared in materials.js code', () => {
// Mutation: move const _techMatCache = new Map() into materials.js → RED
// (teardown accesses it directly via the factory-scope const).
assert.doesNotMatch(stripComments(src()), /const _techMatCache\s*=/,
'_techMatCache must not be declared in materials.js — it stays in screen.js factory scope for teardown');
});
// ── 14. txtMat caches and returns a SpriteMaterial ───────────────────────────
test('txtMat creates and caches a SpriteMaterial on cache miss', () => {
// Mutation: remove `cache[k] = mat` → txtMat allocates a new material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { txtMat } = createMaterialBuilders(di);
const m1 = txtMat('5', '#ff0000', false, 'noteFret');
assert.ok(m1, 'txtMat must return a material');
const m2 = txtMat('5', '#ff0000', false, 'noteFret');
assert.strictEqual(m1, m2, 'txtMat must return the same instance on cache hit');
const m3 = txtMat('5', '#00ff00', false, 'noteFret');
assert.notStrictEqual(m1, m3, 'different color → different material');
});
// ── 15. triMat cache uses techMatCache (DI param), not a local Map ───────────
test('triMat stores results in techMatCache and returns cached entry', () => {
// Mutation: return a fresh material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { triMat } = createMaterialBuilders(di);
assert.strictEqual(di.techMatCache.size, 0, 'techMatCache starts empty');
const m1 = triMat(true, 0xff0000);
assert.strictEqual(di.techMatCache.size, 1, 'triMat must populate techMatCache');
const m2 = triMat(true, 0xff0000);
assert.strictEqual(m1, m2, 'triMat cache hit must return same object');
});
// ── 16. pool warm() pre-allocates and warm() is idempotent ───────────────────
test('pool.warm pre-allocates up to cap and is idempotent past cap', () => {
// Mutation: remove while-loop in warm() → warm() allocates nothing → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { pool } = createMaterialBuilders(di);
const parent = { add() {} };
let mkCount = 0;
const p = pool(parent, () => { mkCount++; return { visible: true, center: null }; });
p.warm(5);
assert.strictEqual(mkCount, 5, 'warm(5) must pre-allocate 5 objects');
p.warm(3); // below current length — must be idempotent
assert.strictEqual(mkCount, 5, 'warm(3) after warm(5) must not allocate more');
p.warm(8);
assert.strictEqual(mkCount, 8, 'warm(8) after warm(5) must allocate 3 more');
});

Some files were not shown because too many files have changed in this diff Show More