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
This commit is contained in:
byrongamatos
2026-09-05 18:32:25 +02:00
co-authored by Claude Sonnet 4.6
parent 930b492fa5
commit e230629770
5 changed files with 1249 additions and 759 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.47.0",
"version": "3.48.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+48 -756
View File
@@ -17,6 +17,7 @@ import { createFx } from './src/fx.js'; // h3d-carve-8
import { createCamera } from './src/camera.js'; // h3d-carve-9
import { createScoreFx } from './src/score-fx.js'; // h3d-carve-10
import { createStringGlow } from './src/string-glow.js'; // h3d-carve-11
import { createArp } from './src/arp.js'; // h3d-carve-12
(function () {
'use strict';
@@ -6825,212 +6826,52 @@ import { createStringGlow } from './src/string-glow.js'; // h3d-carve-11
}
/** Tolerate RS/sloppak boolean-ish ``true`` / ``1`` forms. */
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);
}
/**
* 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;
}
/**
* 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();
/* ── h3d-carve-12: T-section (arpeggio inference) → src/arp.js ─── */
const {
chordWireHighDensity,
chordTemplateLabel,
chordTemplateMarkedArpeggio,
chordHandShapeArpeggioHint,
mergeHandShapeSynthChords,
mergeChordShape,
resetChordShapeCache,
inferArpeggioFromNotePattern,
chordShapeCoveredByStandaloneNotes,
hsStart,
hsEnd,
handShapeChartSpanSec,
fillArpeggioGhostInferFlags,
arpeggioChordIdForNoteWithInferCache,
arpHsBoundsForNote,
fillLaneRailHandShapeFlags,
fillArpeggioRailShapeBoundsCaches,
arpeggioLaneOuterRailLaneSlice,
arpeggioLaneOuterRailAtChartTime,
arpeggioLaneDividerFrameAccentMul,
arpeggioLaneDividerXYScaleMatchFrameRim,
} = createArp({
validString,
filterValidNotes,
sY,
K,
S_GAP,
BEHIND,
CHORD_FRAME_RIM_MIN,
CHORD_FRAME_RIM_FRAC_H,
ARP_FRAME_ONSET_PAD_S,
ARP_FRAME_ONSET_CLUSTER_S,
ARP_INFER_MIN_HAND_SHAPE_SPAN_S,
ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S,
ARP_INFER_MULTI_STRUM_HIT_SLACK,
ARP_INFER_MULTI_STRUM_WIN_MIN_S,
ARP_INFER_MIN_HITS_VS_SHAPE_CAP,
ARP_HWY_RAIL_END_TAIL_S,
ARP_HWY_RAIL_START_LEAD_S,
NEXT_ON_STRING_T_EPS,
getNStr: () => nStr,
});
// h3d-carve-12: _resetStringDependentCaches stays here; only _chordShapeCache
// moved to src/arp.js. Calls resetChordShapeCache() instead of direct assignment.
// 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
@@ -7038,7 +6879,7 @@ import { createStringGlow } from './src/string-glow.js'; // h3d-carve-11
function _resetStringDependentCaches() {
_filterValidNotesCache = new WeakMap();
_chordSigCache = new WeakMap();
_chordShapeCache = new WeakMap();
resetChordShapeCache(); // h3d-carve-12: _chordShapeCache moved to src/arp.js
// mergeHandShapeSynthChords() is nStr-dependent too: its synth
// notes come from chordNotesFromTemplate() -> validString(). The
// merge result is memoised by input identity (not nStr), so force a
@@ -7046,555 +6887,6 @@ import { createStringGlow } from './src/string-glow.js'; // h3d-carve-11
// chords after the count grows.
_mergeCacheResult = null;
}
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;
}
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);
}
/**
* 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;
}
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(nStr - 1);
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);
}
/* ── Fret-label measure-skip rule ───────────────────────────────── */
// For each (note_time, fret) pair across standalone notes and chord
+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, // const fn at line 4025: 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)
};
}
+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');
});
+4 -2
View File
@@ -11,13 +11,15 @@ 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');
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)',
);
});