diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 3e545f9..8ad5581 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.47.0", + "version": "3.48.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index bffd723..3b4f989 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -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 ```` + per-string - * ```` rows but **no** ```` 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 ```` 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 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; - } - - /** ```` 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 ```` 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 - // . 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 diff --git a/plugins/highway_3d/src/arp.js b/plugins/highway_3d/src/arp.js new file mode 100644 index 0000000..f09cd0f --- /dev/null +++ b/plugins/highway_3d/src/arp.js @@ -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 ```` + per-string + * ```` rows but **no** ```` 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 ```` 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 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; + } + + /** ```` 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 ```` 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 + // . 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) + }; +} diff --git a/tests/js/highway_3d_arp.test.js b/tests/js/highway_3d_arp.test.js new file mode 100644 index 0000000..9cccd38 --- /dev/null +++ b/tests/js/highway_3d_arp.test.js @@ -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'); +}); diff --git a/tests/js/highway_3d_arp_deferral.test.js b/tests/js/highway_3d_arp_deferral.test.js index 7a6dfdc..a9c7bcd 100644 --- a/tests/js/highway_3d_arp_deferral.test.js +++ b/tests/js/highway_3d_arp_deferral.test.js @@ -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)', ); });