// h3d-carve-12: T-section (arpeggio inference) extracted from screen.js. // VERBATIM-MOVE: all function bodies are byte-for-byte identical to their // screen.js originals except for the single DI rewire noted below. // No logic changes, no new guards, no structural additions. // // Beyond-subst changes: // 1. arpeggioLaneDividerXYScaleMatchFrameRim: `nStr` → `getNStr()` // (the explicit argument `sY(nStr - 1)` — sY itself is a plain shorthand // that already captures live state internally, but the argument needs getNStr()) // 2. _resetStringDependentCaches: STAYS in screen.js; this module exports // `resetChordShapeCache()` instead, which screen.js calls to reset // _chordShapeCache (the only cache that moved here). // // lowerBoundT imported directly from ./geometry.js — not in DI surface. // Total DI params: 19 (18 plain const shorthand + 1 live getter). // Missed in contract survey (corrected before GO): NEXT_ON_STRING_T_EPS (line 248). import { lowerBoundT } from './geometry.js'; export function createArp({ // ── plain const shorthand (fn refs or number consts, never reassigned) ── validString, // IIFE fn decl ~line 3736 filterValidNotes, // IIFE fn decl ~line 3767 — used by arpeggioLaneDividerFrameAccentMul sY, // factory-scope fn: s => S_BASE + (...) * S_GAP (captures live vars) K, // module-level const line 124 S_GAP, // module-level const line 203 BEHIND, // module-level const line 206 CHORD_FRAME_RIM_MIN, // line 610 CHORD_FRAME_RIM_FRAC_H, // line 611 ARP_FRAME_ONSET_PAD_S, // line 620 ARP_FRAME_ONSET_CLUSTER_S, // line 621 ARP_INFER_MIN_HAND_SHAPE_SPAN_S, // line 628 ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S, // line 634 ARP_INFER_MULTI_STRUM_HIT_SLACK, // line 640 ARP_INFER_MULTI_STRUM_WIN_MIN_S, // line 642 ARP_INFER_MIN_HITS_VS_SHAPE_CAP, // line 653 ARP_HWY_RAIL_END_TAIL_S, // line 263 ARP_HWY_RAIL_START_LEAD_S, // line 265 NEXT_ON_STRING_T_EPS, // line 248 — used in chordShapeCoveredByStandaloneNotes // ── live getter (let var, reassigned per arrangement/frame) ── getNStr, // () => nStr — used in arpeggioLaneDividerXYScaleMatchFrameRim }) { // ── Pre-arp utilities ───────────────────────────────────────────── function truthyChartFlag(v) { if (v === true || v === 1) return true; if (v === '1') return true; return typeof v === 'string' && v.toLowerCase() === 'true'; } /** RS / sloppak `hd` (highDensity); tolerate occasional string forms. */ function chordWireHighDensity(ch) { return truthyChartFlag(ch && ch.hd); } /** * Per spec, `displayName` is the UI label for a chord template * (defaulting to `name` when the chart didn't set it). Always go * through this helper so name vs. displayName drift can't surface * the wrong label or break displayName-based dedupe heuristics. */ function chordTemplateLabel(tmpl) { if (!tmpl) return ''; const d = tmpl.displayName; if (typeof d === 'string' && d.length > 0) return d; const n = tmpl.name; return typeof n === 'string' ? n : ''; } /** * Arpeggio styling is driven by authored metadata, not by post-hoc * note-stream inference. Prefer explicit hand-shape flags and fall back * to template markers when present. */ function chordTemplateMarkedArpeggio(cid, chordTemplates) { if (cid == null || !chordTemplates) return false; const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)]; if (!tmpl) return false; if (truthyChartFlag(tmpl.arp) || truthyChartFlag(tmpl.arpeggio)) return true; const displayName = typeof tmpl.displayName === 'string' ? tmpl.displayName.toLowerCase() : ''; if (displayName.includes('-arp')) return true; const name = typeof tmpl.name === 'string' ? tmpl.name.toLowerCase() : ''; return name.endsWith('(arp)') || name.includes(' arpeggio'); } function handShapeMarkedArpeggio(hs, chordTemplates) { if (!hs) return false; if (truthyChartFlag(hs.arp) || truthyChartFlag(hs.arpeggio)) return true; return chordTemplateMarkedArpeggio(hsChordIdNorm(hs), chordTemplates); } // ── Hint cache ─────────────────────────────────────────────────── /** * Matching hand-shape metadata for a chord onset. ``explicit`` follows * authored arpeggio markers only; note inference is handled separately * by the callers that still need it for non-visual behavior. * * Cached per chord: result depends only on (ch, hss, chordTemplates), * all chart-static for the lifetime of an arrangement. The cache is * swapped on (hss, templates) ref change so an arrangement switch * cannot resurrect stale entries. Empty-input case bypasses the cache * — it returns a fresh sentinel anyway and isn't hot enough to share. */ const _HINT_NONE = Object.freeze({ explicit: false, covered: false, hs: null }); let _hintCache = new WeakMap(); let _hintCacheHsRef = null; let _hintCacheTplRef = null; function chordHandShapeArpeggioHint(ch, hss, chordTemplates) { if (!hss || hss.length === 0) return _HINT_NONE; if (_hintCacheHsRef !== hss || _hintCacheTplRef !== chordTemplates) { _hintCache = new WeakMap(); _hintCacheHsRef = hss; _hintCacheTplRef = chordTemplates; } const cached = _hintCache.get(ch); if (cached !== undefined) return cached; const t = ch.t; const cid = ch.id; let result = _HINT_NONE; for (let i = 0; i < hss.length; i++) { const hs = hss[i]; const tLo = hsStart(hs); const tHi = hsEnd(hs); if (Number.isNaN(tLo) || Number.isNaN(tHi)) continue; if (t + 1e-4 < tLo || t > tHi + 1e-4) continue; const hsCid = hsChordIdNorm(hs); if (hsCid !== cid && Number(hsCid) !== Number(cid)) continue; const explicit = handShapeMarkedArpeggio(hs, chordTemplates); result = { explicit, covered: true, hs }; break; } _hintCache.set(ch, result); return result; } /** Build ``ch.notes`` from ``chordTemplates[cid].frets`` (-1 omitted). */ function chordNotesFromTemplate(cid, templates) { if (templates == null || cid == null) return []; const tmpl = templates[cid] ?? templates[Number(cid)]; if (!tmpl || !Array.isArray(tmpl.frets)) return []; const out = []; for (let si = 0; si < tmpl.frets.length; si++) { const f = tmpl.frets[si]; if (f >= 0 && validString(si)) out.push({ s: si, f, sus: 0 }); } return out; } /** * Chart-format fingerpicking passages often have ```` + 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) }; }