Files
feedBack/plugins/highway_3d/src/arp.js
T
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

851 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
};
}