From 36cf77dc448f82a1b2497bc8fefe44c196139959 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 12 Jul 2026 13:00:29 +0200 Subject: [PATCH] refactor(highway): carve the 2D drawing layer into highway-draw.js (R3c) (#917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 functions, 1,245 lines. highway.js 3,972 -> 2,727 (-31%). The biggest R3c slice: notes, sustains, chords, strum groups, unison bends and lyrics — everything the default renderer paints each frame. ━━━ MUTABILITY, NOT LOCATION, DECIDES WHERE A THING BELONGS ━━━ Three per-instance caches came out with this slice, and they are why it needed care: _frameMismatchWarned a warn-once Set of chord ids (feedBack#88) _chordRenderInfo a WeakMap of chord -> chain info _lyricMeasureCache Map> All three are MUTATED. Left at module scope they would be SHARED ACROSS PANELS — one highway's lyric widths and chord chains stomping another's, silently, with nothing throwing. createHighway() is a factory (the constitution publishes window.createHighway so a plugin can build a second highway), so they are lifted onto hwState, which is exactly what hwState is for. The shimmer LUT went the OTHER way — to MODULE scope in highway-geometry.js. It is a deterministic xorshift table, byte-for-byte identical for every instance, so sharing it is not merely safe but BETTER: built once for the page rather than once per panel. Same slice, opposite directions, decided entirely by whether the thing mutates. ━━━ MY SCRIPT WAS WRONG TWICE. THE GATES CAUGHT BOTH. ━━━ 1. HAND-LISTED THE MOVE SET. I listed 10 functions and missed six that drawChords needs (_ensureChordRenderCache, bsearchChords, getChordTemplateInfo, _computeChordBox, _updateFretLinePreview, _drawFretLineChordPreview). The no-undef gate named every one. The set is now DERIVED from the dependency closure — 18, not 10. 2. JUDGED PURITY TOO EARLY, and this one is subtle. I classified _computeChordBox as pure because its ORIGINAL body never mentions hwState. Then the call-site rewriter injected `fretX(hwState, …)` INTO it — fretX takes hwState now (#916) — leaving a function that references an hwState it was never given. Purity has to be judged from the body AS IT WILL BE, so the classifier iterates to a fixed point: a function needs hwState if it mentions it, OR calls anything that now takes it. That moved _computeChordBox to the stateful side. VERIFIED. A/B against origin/main: IDENTICAL, zero page errors. The PLUGIN BUNDLE contract is byte-identical (b.fretX arity 3, b.getNoteState arity 2, both stable references, both correct under the old calling convention). PERF GATE PASSES AT 1.92ms against its 12ms budget — and this is the slice that could really have cost something: the ENTIRE per-frame drawing path is now cross-module. It costs nothing measurable. TESTS. highway_teaching_marks follows strumGroupBuckets to the new module. The two source-shape harnesses now read highway.js AND every static/js/highway-*.js, rather than being re-pinned at whichever file currently holds a function — re-pinning breaks again next time, and a shape assertion that silently stops finding its target is indistinguishable from one that passes. node 1045, pytest 2416, ESLint 0, no-undef 0, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) --- static/highway.js | 1315 +------------------ static/js/highway-draw.js | 1303 ++++++++++++++++++ static/js/highway-geometry.js | 26 +- tests/js/highway_chord_render_cache.test.js | 19 +- tests/js/highway_note_state.test.js | 34 +- tests/js/highway_teaching_marks.test.js | 2 +- 6 files changed, 1402 insertions(+), 1297 deletions(-) create mode 100644 static/js/highway-draw.js diff --git a/static/highway.js b/static/highway.js index 8b18f40..cefbf56 100644 --- a/static/highway.js +++ b/static/highway.js @@ -40,6 +40,7 @@ import { roundRect, teachingDegreeLabel, teachingFingerLabel, + _shimmerNoise, } from './js/highway-geometry.js'; import { _noteState, @@ -47,6 +48,26 @@ import { fillTextReadable, fretX, } from './js/highway-state-primitives.js'; +import { + _chordHasTechniqueFlags, + _computeChordBox, + _drawFretLineChordPreview, + _ensureChordRenderCache, + _measureLyricText, + _noteHasTechniqueFlags, + _updateFretLinePreview, + bsearch, + bsearchChords, + drawChords, + drawLyrics, + drawNote, + drawNotes, + drawStrumGroups, + drawSustains, + drawUnisonBends, + getChordTemplateInfo, + strumGroupBuckets, +} from './js/highway-draw.js'; function createHighway() { // R3c: per-instance mutable state in one object, so extracted renderer/ws @@ -321,9 +342,9 @@ function createHighway() { hwState._lefty = localStorage.getItem('lefty') === '1'; hwState._lastChordOnFretLine = null; // chord object currently shown on fret line hwState._chordFretLineNotes = []; // notes to render on fret line - const _frameMismatchWarned = new Set(); // chord ids already warned about (feedBack#88) + hwState._frameMismatchWarned = new Set(); // chord ids already warned about (feedBack#88) // Per-chord render info, computed lazily once per src array (feedBack#88). - const _chordRenderInfo = new WeakMap(); // chord -> { chainIndex, chainLen, isFull, baseFret, sortedNotes, nonZeroNotes, nonZeroFrets, allMuted, hasMultipleNotes } + hwState._chordRenderInfo = new WeakMap(); // chord -> { chainIndex, chainLen, isFull, baseFret, sortedNotes, nonZeroNotes, nonZeroFrets, allMuted, hasMultipleNotes } hwState._chordRenderCacheSrc = null; hwState._chordRenderCacheInverted = null; // Also invalidate when chordTemplates is reassigned (WS 'chord_templates' @@ -335,37 +356,8 @@ function createHighway() { // shimmer). Incremented once per rAF in draw(). hwState._frameIdx = 0; - const _shimmerLut = new Float32Array(_SHIMMER_LUT_SIZE); - for (let i = 0; i < _SHIMMER_LUT_SIZE; i++) { - let x = (i + 1) | 0; // +1 dodges the all-zero xorshift trap - x ^= x << 13; - x ^= x >>> 17; - x ^= x << 5; - _shimmerLut[i] = (x >>> 0) / 4294967296; - } - function _shimmerNoise(seed) { - // Mask works only because _SHIMMER_LUT_SIZE is a power of two - // (see comment at the LUT declaration above). - return _shimmerLut[(seed >>> 0) & (_SHIMMER_LUT_SIZE - 1)]; - } - - const _lyricMeasureCache = new Map(); // Map> - function _measureLyricText(c, fontSize, text) { - let inner = _lyricMeasureCache.get(fontSize); - if (inner === undefined) { - if (_lyricMeasureCache.size >= _LYRIC_MEASURE_OUTER_MAX) _lyricMeasureCache.clear(); - inner = new Map(); - _lyricMeasureCache.set(fontSize, inner); - } - let w = inner.get(text); - if (w === undefined) { - if (inner.size >= _LYRIC_MEASURE_INNER_MAX) inner.clear(); - w = c.measureText(text).width; - inner.set(text, w); - } - return w; - } + hwState._lyricMeasureCache = new Map(); // Map> hwState.STRING_COLORS = DEFAULT_STRING_COLORS.slice(); hwState.STRING_DIM = DEFAULT_STRING_DIM.slice(); hwState.STRING_BRIGHT = DEFAULT_STRING_BRIGHT.slice(); @@ -463,19 +455,6 @@ function createHighway() { * Returns the groups (in first-seen order) for each ch value >= 0 that has * at least two members — a lone note is not a strum gesture. Pure; drives * the strum-bracket overlay and is node-tested. */ - function strumGroupBuckets(items) { - if (!Array.isArray(items)) return []; - const order = []; - const byKey = new Map(); - for (const it of items) { - const ch = it && Number.isInteger(it.ch) ? it.ch : -1; - if (ch < 0) continue; - if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); } - byKey.get(ch).push(it); - } - return order.map(k => byKey.get(k)).filter(g => g.length >= 2); - } - /** Call while lefty mirror transform is active; keeps glyphs readable. */ // Stable bundle accessor for the registered provider — see // bundle.getNoteStateProvider below. Defined once per createHighway() @@ -684,10 +663,10 @@ function createHighway() { drawFretLines(W, H); drawBeats(W, H); drawStrings(W, H); - drawSustains(W, H); + drawSustains(hwState, W, H); drawNowLine(W, H); - drawNotes(W, H); - drawChords(W, H); + drawNotes(hwState, W, H); + drawChords(hwState, W, H); drawFretNumbers(W, H); // Plugin draw hooks (same coordinate system as the highway). @@ -703,7 +682,7 @@ function createHighway() { hwState.ctx.restore(); // Lyrics: drawn unmirrored so lines stay left-to-right readable (layout is center-symmetric) - if (hwState.showLyrics) drawLyrics(W, H); + if (hwState.showLyrics) drawLyrics(hwState, W, H); } catch (e) { console.error('draw error:', e); } @@ -1396,859 +1375,6 @@ function createHighway() { hwState.ctx.stroke(); } - function drawNote(W, H, x, y, scale, string, fret, opts, ns) { - // ns (feedBack#254): normalized judgment state from _noteState, - // or null/undefined. `lit` means render the gem in the bright - // string colour with an additive halo; a miss gets a faint red - // wash instead. ns absent → byte-for-byte the original render. - const lit = !!(ns && ns.state !== 'miss'); - const isHarmonic = opts?.hm || opts?.hp || false; - const isPinchHarmonic = opts?.hp || false; - const isChord = opts?.chord || false; - const bend = opts?.bn || 0; - const slide = opts?.sl ?? -1; // pitched slide-to fret (-1 = none; 0 = slide to open) - const slu = opts?.slu ?? -1; // unpitched slide-to fret (-1 = none) - const hammerOn = opts?.ho || false; - const pullOff = opts?.po || false; - const tap = opts?.tp || false; - const palmMute = opts?.pm || false; - const tremolo = opts?.tr || false; - const accent = opts?.ac || false; - const sz = Math.max(12, 80 * scale * (H / 900)); - const half = sz / 2; - // When lit, bump the body one step brighter and the backing-glow - // one step up from STRING_DIM, so even shapes that don't get the - // _paintGemGlow halo (the open-string bar) read as "lit". - const color = lit ? (ns.color || hwState.STRING_BRIGHT[string] || hwState.STRING_COLORS[string] || '#888') : (hwState.STRING_COLORS[string] || '#888'); - const dark = lit ? (hwState.STRING_COLORS[string] || '#666') : (hwState.STRING_DIM[string] || '#222'); - - if (sz < 6) { - hwState.ctx.fillStyle = color; - hwState.ctx.beginPath(); - hwState.ctx.arc(x, y, 2, 0, Math.PI * 2); - hwState.ctx.fill(); - return; - } - - // Open string: wide bar spanning the highway (only for standalone notes) - if (fret === 0 && !isChord) { - const hw = W * 0.26 * scale; - const barH = Math.max(6, sz * 0.45); - // Shadow - hwState.ctx.fillStyle = dark; - roundRect(hwState.ctx, W/2 - hw - 1, y - barH/2 - 1, hw * 2 + 2, barH + 2, 3); - hwState.ctx.fill(); - // Body - hwState.ctx.fillStyle = color; - roundRect(hwState.ctx, W/2 - hw, y - barH/2, hw * 2, barH, 2); - hwState.ctx.fill(); - // Judgment glow (feedBack#254) — central halo on the bar. - // _paintGemGlow takes a half-extent; barH is the full bar height. - _paintGemGlow(hwState, W/2, y, barH * 0.5, string, ns); - // "0" label - const fontSize = Math.max(8, sz * 0.5) | 0; - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${fontSize}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'middle'; - fillTextReadable(hwState, '0', W/2, y); - - // Technique labels on open strings — PM, H/P/T, tremolo, and - // accent markers are all meaningful on fret 0. Bend and slide - // are omitted because they reference a fret position that the - // centered bar doesn't visually convey. Matches the sz<14 gate - // the fretted path uses so labels don't render on tiny bars. - // Fixes #21. - if (sz >= 14) { - // H / P / T above - if (hammerOn || pullOff || tap) { - const label = tap ? 'T' : (hammerOn ? 'H' : 'P'); - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'bottom'; - fillTextReadable(hwState, label, W/2, y - barH/2 - 4); - } - // PM below - if (palmMute) { - hwState.ctx.fillStyle = '#aaa'; - hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'top'; - fillTextReadable(hwState, 'PM', W/2, y + barH/2 + 2); - } - // Tremolo (wavy line above) - if (tremolo) { - const ty = y - barH/2 - 6; - hwState.ctx.strokeStyle = '#ff0'; - hwState.ctx.lineWidth = 1.5; - hwState.ctx.beginPath(); - for (let i = -3; i <= 3; i++) { - const wx = W/2 + i * sz * 0.08; - const wy = ty + Math.sin(i * 2) * 3; - if (i === -3) hwState.ctx.moveTo(wx, wy); - else hwState.ctx.lineTo(wx, wy); - } - hwState.ctx.stroke(); - } - // Accent caret above - if (accent) { - const ay2 = y - barH/2 - 4; - hwState.ctx.strokeStyle = '#fff'; - hwState.ctx.lineWidth = 2; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(W/2 - sz * 0.2, ay2 + 3); - hwState.ctx.lineTo(W/2, ay2 - 2); - hwState.ctx.lineTo(W/2 + sz * 0.2, ay2 + 3); - hwState.ctx.stroke(); - } - } - return; - } - - if (isHarmonic) { - // Diamond shape for harmonics - const dh = half * 1.15; - // Glow - hwState.ctx.fillStyle = dark; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x, y - dh - 3); hwState.ctx.lineTo(x + half + 3, y); - hwState.ctx.lineTo(x, y + dh + 3); hwState.ctx.lineTo(x - half - 3, y); - hwState.ctx.closePath(); hwState.ctx.fill(); - // Body - hwState.ctx.fillStyle = color; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x, y - dh); hwState.ctx.lineTo(x + half, y); - hwState.ctx.lineTo(x, y + dh); hwState.ctx.lineTo(x - half, y); - hwState.ctx.closePath(); hwState.ctx.fill(); - // Bright outline - hwState.ctx.strokeStyle = hwState.STRING_BRIGHT[string] || '#fff'; - hwState.ctx.lineWidth = 2; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x, y - dh); hwState.ctx.lineTo(x + half, y); - hwState.ctx.lineTo(x, y + dh); hwState.ctx.lineTo(x - half, y); - hwState.ctx.closePath(); hwState.ctx.stroke(); - // PH label for pinch harmonics - if (isPinchHarmonic && sz >= 14) { - hwState.ctx.fillStyle = '#ff0'; - hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'top'; - fillTextReadable(hwState, 'PH', x, y + dh + 2); - } - } else { - // Glow - hwState.ctx.fillStyle = dark; - roundRect(hwState.ctx, x - half - 4, y - half - 4, sz + 8, sz + 8, sz / 3); - hwState.ctx.fill(); - // Body - hwState.ctx.fillStyle = color; - roundRect(hwState.ctx, x - half, y - half, sz, sz, sz / 5); - hwState.ctx.fill(); - } - - // Judgment glow (feedBack#254) — additive halo for a correct - // hit / held sustain, faint red wash for a miss. Drawn before - // the fret number so the number stays legible on top. - _paintGemGlow(hwState, x, y, isHarmonic ? half * 1.2 : half, string, ns); - - // Fret number - const fontSize = Math.max(10, sz * 0.5) | 0; - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${fontSize}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'middle'; - fillTextReadable(hwState, String(fret), x, y); - - // Bend notation - if (bend && bend > 0 && sz >= 12) { - const lw = Math.max(2, sz / 10); - const ay = y - half - 4; - // px above the gem for a bend of `v` semitones (shared by the - // curve contour and the scalar-arrow fallback). - const hOf = (v) => sz * 0.55 * Math.min(Math.max(v, 0), 2); - const bnv = Array.isArray(opts?.bnv) ? opts.bnv : null; - - hwState.ctx.strokeStyle = '#fff'; - hwState.ctx.lineWidth = lw; - - let labelTopY; // y of the highest drawn point, for the label - if (bnv && bnv.length >= 2) { - // Bend curve (§6.2.1): trace the real shape as a contour above - // the gem (round-trip rises then falls, pre-bend starts high, - // release descends, …) — `bt` is implicit in the point shape. - const pts = bnvNormalizedPoints(bnv, opts?.sus); - const gw = sz * 0.6; - const x0 = x - gw / 2; - hwState.ctx.beginPath(); - pts.forEach((pt, i) => { - const px = x0 + pt.x * gw; - const py = ay - hOf(pt.v); - if (i === 0) hwState.ctx.moveTo(px, py); else hwState.ctx.lineTo(px, py); - }); - hwState.ctx.stroke(); - // Arrowhead only when the gesture ends rising (plain bend / - // pre-bend); round-trip and release finish heading down. - const a = pts[pts.length - 2], b = pts[pts.length - 1]; - if (b.v > a.v + 0.05) { - const tipX = x0 + b.x * gw, tipY = ay - hOf(b.v); - hwState.ctx.beginPath(); - hwState.ctx.moveTo(tipX - sz * 0.1, tipY + sz * 0.12); - hwState.ctx.lineTo(tipX, tipY); - hwState.ctx.lineTo(tipX + sz * 0.1, tipY + sz * 0.12); - hwState.ctx.stroke(); - } - labelTopY = ay - hOf(Math.max(...pts.map(p => p.v))); - } else { - // Fallback: single curved arrow up to the scalar peak. - const arrowH = hOf(bend); // taller for bigger bends - const tipY = ay - arrowH; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x, ay); - hwState.ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); - hwState.ctx.stroke(); - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); - hwState.ctx.lineTo(x, tipY); - hwState.ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); - hwState.ctx.stroke(); - labelTopY = tipY; - } - - // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2" - let label; - if (bend === 0.5) label = '½'; - else if (bend === 1) label = 'full'; - else if (bend === 1.5) label = '1½'; - else if (bend === 2) label = '2'; - else label = bend.toFixed(1); - - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'bottom'; - fillTextReadable(hwState, label, x, labelTopY - 2); - } - - if (sz < 14) return; // Skip small technique labels - - // Teaching marks (§6.2.2) — display only, never grading. The fret-hand - // finger (fg) renders by default as a small numeral hugging the gem's - // right edge (T = thumb, 1..4), hideable via the finger-hints toggle; the - // scale degree (sd) is opt-in and sits on the left edge so the two never - // collide with the centred fret number. - const fgLabel = hwState._showFingerHints ? teachingFingerLabel(opts?.fg) : ''; - if (fgLabel) { - hwState.ctx.fillStyle = '#7fd1ff'; - hwState.ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; - hwState.ctx.textAlign = 'left'; - hwState.ctx.textBaseline = 'middle'; - fillTextReadable(hwState, fgLabel, x + half + 2, y + half * 0.5); - } - if (hwState._showTeachingMarks) { - const sdLabel = teachingDegreeLabel(opts?.sd); - if (sdLabel) { - hwState.ctx.fillStyle = '#ffcc66'; - hwState.ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; - hwState.ctx.textAlign = 'right'; - hwState.ctx.textBaseline = 'middle'; - fillTextReadable(hwState, sdLabel, x - half - 2, y + half * 0.5); - } - } - - // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to - // the target fret; unpitched (slu) draws a dashed diagonal with no - // arrowhead (no definite target pitch). The two are mutually exclusive - // in the data; the 3D highway makes the same pitched/unpitched split. - if (slide >= 0 || slu >= 0) { - const pitched = slide >= 0; - const target = pitched ? slide : slu; - const dir = target > fret ? -1 : 1; // up or down the neck; mirror handles lefty - hwState.ctx.strokeStyle = '#fff'; - hwState.ctx.lineWidth = Math.max(2, sz / 10); - if (!pitched) hwState.ctx.setLineDash([Math.max(2, sz / 8), Math.max(2, sz / 8)]); - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x - sz * 0.3, y + dir * sz * 0.3); - hwState.ctx.lineTo(x + sz * 0.3, y - dir * sz * 0.3); - hwState.ctx.stroke(); - if (!pitched) hwState.ctx.setLineDash([]); - // Arrowhead only for a pitched slide (definite target pitch). - if (pitched) { - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x + sz * 0.3, y - dir * sz * 0.3); - hwState.ctx.lineTo(x + sz * 0.15, y - dir * sz * 0.15); - hwState.ctx.stroke(); - } - } - - // H/P/T label above note - if (hammerOn || pullOff || tap) { - const label = tap ? 'T' : (hammerOn ? 'H' : 'P'); - const ly = y - half - (bend > 0 ? sz * 0.6 : 4); - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'bottom'; - fillTextReadable(hwState, label, x, ly); - } - - // Palm mute (PM below note) - if (palmMute) { - hwState.ctx.fillStyle = '#aaa'; - hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'top'; - fillTextReadable(hwState, 'PM', x, y + half + 2); - } - - // Tremolo (wavy line above) - if (tremolo) { - const ty = y - half - (bend > 0 ? sz * 0.7 : 6); - hwState.ctx.strokeStyle = '#ff0'; - hwState.ctx.lineWidth = 1.5; - hwState.ctx.beginPath(); - for (let i = -3; i <= 3; i++) { - const wx = x + i * sz * 0.08; - const wy = ty + Math.sin(i * 2) * 3; - if (i === -3) hwState.ctx.moveTo(wx, wy); - else hwState.ctx.lineTo(wx, wy); - } - hwState.ctx.stroke(); - } - - // Accent (> marker) - if (accent) { - const ay2 = y - half - 4; - hwState.ctx.strokeStyle = '#fff'; - hwState.ctx.lineWidth = 2; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x - sz * 0.2, ay2 + 3); - hwState.ctx.lineTo(x, ay2 - 2); - hwState.ctx.lineTo(x + sz * 0.2, ay2 + 3); - hwState.ctx.stroke(); - } - } - - function drawSustains(W, H) { - // Same master-difficulty fallback as drawNotes/drawChords — - // without this, sustain bars for filtered-out notes would - // still render, leaving orphan rectangles where no note head - // is drawn. - const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; - for (const n of src) { - if (n.sus <= 0.01) continue; - const end = n.t + n.sus; - if (end < hwState.currentTime || n.t > hwState.currentTime + VISIBLE_SECONDS) continue; - - const t0 = Math.max(n.t - hwState.currentTime, 0); - const t1 = Math.min(end - hwState.currentTime, VISIBLE_SECONDS); - if (t0 >= t1) continue; - - const p0 = project(t0), p1 = project(t1); - if (!p0 || !p1) continue; - - const x0 = fretX(hwState, n.f, p0.scale, W); - const x1 = fretX(hwState, n.f, p1.scale, W); - const sw0 = Math.max(2, 6 * p0.scale); - const sw1 = Math.max(2, 6 * p1.scale); - - // feedBack#254 — a sustain that's currently being held - // correctly "sizzles" in the bright string colour (glow + - // flickering brightness + a crackling current down the - // middle); otherwise the usual dim trail. A miss is left dim - // (the gem / overlay marks the miss; a red trail would be - // noisy). Skip the lookup entirely when no provider is set — - // zero cost in the hot loop for the common case. - const ns = hwState._noteStateProvider ? _noteState(hwState, n, n.t) : null; - const litTrail = !!(ns && ns.state !== 'miss'); - const y0 = p0.y * H, y1 = p1.y * H; - if (litTrail) { - const a = ns.alpha; - const col = ns.color || hwState.STRING_BRIGHT[n.s] || hwState.STRING_COLORS[n.s] || '#666'; - // Per-note seed so neighbouring sustains shimmer - // independently. Math.floor(n.t * 60) is stable across - // frames yet drifts on song progression; combined with - // _frameIdx + n.s it gives a non-correlated walk through - // the LUT, matching the original visual intent - // (feedBack#254 comment above). - const seedBase = (hwState._frameIdx + n.s + ((n.t * 60) | 0)) | 0; - hwState.ctx.save(); - hwState.ctx.fillStyle = col; - // Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with - // the blurred DEVICE-pixel area, and a held sustain's trail can - // span half the (DPR-scaled) canvas — profiling the "stutters - // while playing" report put this per-frame blur pass at the top - // exactly while a sustain is held. Three inflated low-alpha - // fills of the same quad read as the same soft glow at a flat, - // area-independent cost. The shimmer LUT still drives the - // per-frame size/brightness flicker (feedBack#254 intent). - const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a; - const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17)); - const fillTrail = (inflate) => { - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x0 - sw0 - inflate, y0); - hwState.ctx.lineTo(x0 + sw0 + inflate, y0); - hwState.ctx.lineTo(x1 + sw1 + inflate, y1); - hwState.ctx.lineTo(x1 - sw1 - inflate, y1); - hwState.ctx.fill(); - }; - hwState.ctx.globalAlpha = baseA * 0.22; - fillTrail(glowPx); - hwState.ctx.globalAlpha = baseA * 0.4; - fillTrail(glowPx * 0.45); - hwState.ctx.globalAlpha = baseA; - fillTrail(0); - // Crackling "current" — a jittery white core line down - // the trail, re-randomised each frame. - hwState.ctx.globalCompositeOperation = 'lighter'; - hwState.ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31)); - hwState.ctx.strokeStyle = '#ffffff'; - hwState.ctx.lineWidth = Math.max(1.5, sw0 * 0.5); - hwState.ctx.lineJoin = 'round'; - hwState.ctx.lineCap = 'round'; - hwState.ctx.beginPath(); - const segs = 7; - for (let k = 0; k <= segs; k++) { - const f = k / segs; - const jx = (k === 0 || k === segs) ? 0 : (_shimmerNoise(seedBase + 47 + k) - 0.5) * sw0 * 2.2; - const xx = x0 + (x1 - x0) * f + jx; - const yy = y0 + (y1 - y0) * f; - if (k === 0) hwState.ctx.moveTo(xx, yy); else hwState.ctx.lineTo(xx, yy); - } - hwState.ctx.stroke(); - hwState.ctx.restore(); - } else { - hwState.ctx.fillStyle = hwState.STRING_DIM[n.s] || '#333'; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x0 - sw0, y0); - hwState.ctx.lineTo(x0 + sw0, y0); - hwState.ctx.lineTo(x1 + sw1, y1); - hwState.ctx.lineTo(x1 - sw1, y1); - hwState.ctx.fill(); - } - } - } - - function drawNotes(W, H) { - // Master-difficulty filter (feedBack#48): when the source had - // phrase-level ladder data, render from the mastery-filtered - // array. _filteredNotes stays null for slider-disabled sources - // so rendering falls through to the flat notes array unchanged. - const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; - // Binary search for visible range - const tMin = hwState.currentTime - 0.25; - const tMax = hwState.currentTime + VISIBLE_SECONDS; - let lo = bsearch(src, tMin); - let hi = bsearch(src, tMax); - - // Include sustained notes - while (lo > 0 && src[lo-1].t + src[lo-1].sus > hwState.currentTime) lo--; - - // Collect drawn positions for unison bend detection - const drawnNotes = []; - - for (let i = hi - 1; i >= lo; i--) { - const n = src[i]; - let tOff = n.t - hwState.currentTime; - - // Hold sustained notes at now line - let p; - if (tOff < -0.05 && n.sus > 0 && n.t + n.sus > hwState.currentTime) { - p = { y: 0.82, scale: 1.0 }; - } else { - p = project(tOff); - } - if (!p) continue; - - const x = fretX(hwState, n.f, p.scale, W); - drawNote(W, H, x, p.y * H, p.scale, n.s, n.f, n, hwState._noteStateProvider ? _noteState(hwState, n, n.t) : null); - drawnNotes.push({ - t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale, - ch: Number.isInteger(n.ch) ? n.ch : -1, - pkd: Number.isInteger(n.pkd) ? n.pkd : -1, - }); - } - - // Draw unison bend connectors - drawUnisonBends(W, H, drawnNotes); - // Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay. - // Scoped to standalone notes (the stream drawNotes renders); chord-note - // strum groups aren't bracketed (the editor authors ch over single-note - // selections, and chord notes already read as one simultaneous gesture). - if (hwState._showTeachingMarks) drawStrumGroups(W, H, drawnNotes); - } - - function drawStrumGroups(W, H, drawnNotes) { - // Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one - // strum/rake gesture. Connect each group's gems with a bracket and a - // single arrowhead whose direction comes from `pkd` (0 = down-strum, - // 1 = up-strum). Display only — never grading. - for (const group of strumGroupBuckets(drawnNotes)) { - const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x); - const scale = pts[0].scale; - const sz = Math.max(12, 80 * scale * (H / 900)); - if (sz < 14) continue; - const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd; - - hwState.ctx.save(); - hwState.ctx.strokeStyle = '#c89bff'; - hwState.ctx.lineWidth = Math.max(2, sz / 12); - hwState.ctx.lineJoin = 'round'; - hwState.ctx.beginPath(); - pts.forEach((p, i) => (i === 0 ? hwState.ctx.moveTo(p.x, p.y) : hwState.ctx.lineTo(p.x, p.y))); - hwState.ctx.stroke(); - // Arrowhead at the gesture start: down-strum (pkd 0) points toward - // the last gem, up-strum (pkd 1) toward the first. - if (pkd === 0 || pkd === 1) { - const head = pkd === 1 ? pts[0] : pts[pts.length - 1]; - const from = pkd === 1 ? pts[1] : pts[pts.length - 2]; - const dy = Math.sign(head.y - from.y) || 1; - const a = sz * 0.18; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(head.x - a, head.y - dy * a); - hwState.ctx.lineTo(head.x, head.y); - hwState.ctx.lineTo(head.x + a, head.y - dy * a); - hwState.ctx.stroke(); - } - hwState.ctx.restore(); - } - } - - function drawUnisonBends(W, H, drawnNotes) { - // Group notes by time (within 0.01s tolerance) - const groups = []; - const used = new Set(); - for (let i = 0; i < drawnNotes.length; i++) { - if (used.has(i)) continue; - const group = [drawnNotes[i]]; - used.add(i); - for (let j = i + 1; j < drawnNotes.length; j++) { - if (used.has(j)) continue; - if (Math.abs(drawnNotes[j].t - drawnNotes[i].t) < 0.01) { - group.push(drawnNotes[j]); - used.add(j); - } - } - if (group.length >= 2) groups.push(group); - } - - for (const group of groups) { - // Find pairs: one with bend, one without (or both with different bends) - const bent = group.filter(n => n.bn > 0); - const unbent = group.filter(n => n.bn === 0); - if (bent.length === 0 || unbent.length === 0) continue; - - // Draw connector between each bent-unbent pair - for (const bn of bent) { - // Find the closest unbent note by string - let closest = unbent[0]; - for (const ub of unbent) { - if (Math.abs(ub.s - bn.s) < Math.abs(closest.s - bn.s)) closest = ub; - } - - const sz = Math.max(12, 80 * bn.scale * (H / 900)); - if (sz < 14) continue; - - // Draw a curved dashed line connecting bent note to target note - const x1 = bn.x, y1 = bn.y; - const x2 = closest.x, y2 = closest.y; - const midX = (x1 + x2) / 2 + sz * 0.5; - const midY = (y1 + y2) / 2; - - hwState.ctx.save(); - hwState.ctx.strokeStyle = '#60d0ff'; - hwState.ctx.lineWidth = Math.max(2, sz / 12); - hwState.ctx.setLineDash([4, 4]); - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x1, y1); - hwState.ctx.quadraticCurveTo(midX, midY, x2, y2); - hwState.ctx.stroke(); - hwState.ctx.setLineDash([]); - hwState.ctx.restore(); - - // "U" label at midpoint - const labelSz = Math.max(10, sz * 0.3) | 0; - hwState.ctx.fillStyle = '#60d0ff'; - hwState.ctx.font = `bold ${labelSz}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'middle'; - const cpX = (x1 + 2 * midX + x2) / 4; - const cpY = (y1 + 2 * midY + y2) / 4; - fillTextReadable(hwState, 'U', cpX + sz * 0.3, cpY); - } - } - } - - function drawChords(W, H) { - // See drawNotes — _filteredChords is null for slider-disabled - // sources so we fall through to the flat chords array. - const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; - _ensureChordRenderCache(src); - - const tMin = hwState.currentTime - 0.25; - const tMax = hwState.currentTime + VISIBLE_SECONDS; - const lo = bsearchChords(src, tMin); - const hi = bsearchChords(src, tMax); - - _updateFretLinePreview(src, lo, hi); - _drawFretLineChordPreview(W, H); - - for (let i = hi - 1; i >= lo; i--) { - const ch = src[i]; - const p = project(ch.t - hwState.currentTime); - if (!p) continue; - - const info = _chordRenderInfo.get(ch); - const { isFull, baseFret, sortedNotes: sorted, nonZeroNotes, nonZeroFrets, allMuted, hasMultipleNotes } = info; - - const sz = Math.max(10, 28 * p.scale * (H / 900)); - const spread = sz * 0.85; - const minSpread = sz + 16 * p.scale; - const actualSpread = Math.max(spread, minSpread); - const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); - - const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates); - const hasNonZero = nonZeroNotes.length >= 1; - - const frameLeftFret = baseFret; - const frameRightFret = baseFret + CHORD_FRAME_FRETS; - - // Frame validation — log once per chord id rather than every frame. - if (hasNonZero && !_frameMismatchWarned.has(ch.id)) { - let notesInFrame = true; - for (let k = 0; k < nonZeroFrets.length; k++) { - const f = nonZeroFrets[k]; - if (f < frameLeftFret || f > frameRightFret) { notesInFrame = false; break; } - } - if (!notesInFrame) { - _frameMismatchWarned.add(ch.id); - console.warn('Chord frame mismatch:', ch.id, { frameLeftFret, frameRightFret, nonZeroFrets }); - } - } - - // X span between fretted notes (excluding open strings) — - // single pass over cached nonZeroFrets, no spread + Math.min/max. - let xMin = null, xMax = null; - if (hasNonZero) { - xMin = Infinity; xMax = -Infinity; - for (let k = 0; k < nonZeroFrets.length; k++) { - const x = fretX(hwState, nonZeroFrets[k], p.scale, W); - if (x < xMin) xMin = x; - if (x > xMax) xMax = x; - } - } - if (allMuted) { - const { boxX, boxW, boxTop, boxH } = _computeChordBox(p, H, W, sorted, sz, actualSpread, baseFret); - - hwState.ctx.strokeStyle = MUTE_BOX_STROKE; - hwState.ctx.lineWidth = Math.max(2, sz / 6); - roundRect(hwState.ctx, boxX, boxTop, boxW, boxH, 2); - hwState.ctx.stroke(); - - hwState.ctx.fillStyle = MUTE_BOX_BAR; - hwState.ctx.fillRect(boxX, boxTop + 2, boxW, 4); - - // Gray X cross, centered in frame - const xInset = sz * 0.6; - const xStartX = boxX + xInset; - const xEndX = boxX + boxW - xInset; - hwState.ctx.beginPath(); - hwState.ctx.moveTo(xStartX, boxTop + sz * 0.5); - hwState.ctx.lineTo(xEndX, boxTop + boxH - sz * 0.5); - hwState.ctx.moveTo(xEndX, boxTop + sz * 0.5); - hwState.ctx.lineTo(xStartX, boxTop + boxH - sz * 0.5); - hwState.ctx.stroke(); - - continue; - } - - // Repeat chord (mid-chain): translucent box + bracket bar. - if (!isFull) { - const { boxX, boxW, boxTop, boxH } = _computeChordBox(p, H, W, sorted, sz, actualSpread, baseFret); - - hwState.ctx.fillStyle = REPEAT_BOX_FILL; - roundRect(hwState.ctx, boxX, boxTop, boxW, boxH, 2); - hwState.ctx.fill(); - - hwState.ctx.fillStyle = REPEAT_BOX_BAR; - hwState.ctx.fillRect(boxX, boxTop + 2, boxW, 4); - - continue; - } - - // First-in-chain (or short chain): full chord rendering. - // Bracket bar above the notes. - if (hasNonZero || sorted.length >= 2) { - const positions = (hasNonZero ? nonZeroNotes : sorted).map((cn, j) => ({ - x: fretX(hwState, cn.f, p.scale, W), - y: p.y * H - actualTotalH / 2 + j * actualSpread, - })); - const barY = positions[0].y - sz * 0.7; - const barLeft = hasNonZero ? xMin : fretX(hwState, frameLeftFret, p.scale, W); - const barRight = hasNonZero ? xMax : fretX(hwState, frameRightFret, p.scale, W); - - hwState.ctx.fillStyle = REPEAT_BOX_BAR; - hwState.ctx.lineWidth = Math.max(3, sz / 4); - roundRect(hwState.ctx, barLeft - 2, barY - 2, barRight - barLeft + 4, 4, 2); - hwState.ctx.fill(); - for (const pos of positions) { - hwState.ctx.fillRect(pos.x - 2, barY, 4, pos.y - sz / 2 - barY); - } - } - - // Chord name label - if (!ch.hd && p.scale > 0.15 && tmpl && tmpl.name) { - const labelY = hasNonZero - ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) - : (p.y * H - sz * 0.8); - const labelX = hasNonZero - ? (xMin + xMax) / 2 - : (sorted.length >= 2 - ? (fretX(hwState, frameLeftFret, p.scale, W) + fretX(hwState, frameRightFret, p.scale, W)) / 2 - : fretX(hwState, sorted[0].f, p.scale, W)); - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${Math.max(14, sz * 0.45) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'bottom'; - fillTextReadable(hwState, tmpl.name, labelX, labelY); - } - - // Harmony annotations (§6.3.1 / §6.6) — the chord's function - // (fn.rn Roman numeral) and template voicing, stacked above the - // chord name. Gated behind the teaching-marks opt-in (same overlay - // class as sd/ch) so they don't clutter the default highway. - // Display only — never grading. - if (hwState._showTeachingMarks && !ch.hd && p.scale > 0.15 && sorted.length > 0) { - const { rn, voicing, caged, guideTones } = chordHarmonyLabels( - ch.fn, tmpl && tmpl.voicing, tmpl && tmpl.caged, tmpl && tmpl.guideTones); - if (rn || voicing || caged || guideTones) { - const hx = hasNonZero - ? (xMin + xMax) / 2 - : (sorted.length >= 2 - ? (fretX(hwState, frameLeftFret, p.scale, W) + fretX(hwState, frameRightFret, p.scale, W)) / 2 - : fretX(hwState, sorted[0].f, p.scale, W)); - // Baseline = just above where the chord name sits. - const nameY = hasNonZero - ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) - : (p.y * H - sz * 0.8); - hwState.ctx.font = `bold ${Math.max(10, sz * 0.32) | 0}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'bottom'; - let stackY = nameY - sz * 0.5; - if (rn) { - hwState.ctx.fillStyle = '#ffcc66'; // matches the sd teaching color - fillTextReadable(hwState, rn, hx, stackY); - stackY -= sz * 0.45; - } - if (voicing) { - hwState.ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color - fillTextReadable(hwState, voicing, hx, stackY); - stackY -= sz * 0.45; - } - if (caged) { - hwState.ctx.fillStyle = '#a0ffa0'; // CAGED shape teaching color - fillTextReadable(hwState, caged, hx, stackY); - stackY -= sz * 0.45; - } - if (guideTones) { - hwState.ctx.fillStyle = '#d0a0ff'; // guide-tone teaching color - fillTextReadable(hwState, guideTones, hx, stackY); - } - } - } - - // Notes — wide colored bar for open strings inside a chord, - // normal note glyph otherwise. - // Classify into bent / unbent arrays inline (was: post-filter - // chordPositions twice into bent/unbent). - const bent = []; - const unbent = []; - - for (let j = 0; j < sorted.length; j++) { - const cn = sorted[j]; - const x = fretX(hwState, cn.f, p.scale, W); - const ny = p.y * H - actualTotalH / 2 + j * actualSpread; - // feedBack#254 — per-string judgment, keyed by the - // chord's chart time (matches how note_detect stores it). - const cnNs = hwState._noteStateProvider ? _noteState(hwState, cn, ch.t) : null; - - // Open-string-in-chord wide bar — only when the note has no - // technique flags. Otherwise fall back to drawNote so PM / - // H / P / T / tremolo / accent labels still render (drawNote - // is the only path that emits those labels). - if (getTemplateFret(cn) === 0 && hasMultipleNotes && !_noteHasTechniqueFlags(cn)) { - const litBar = !!(cnNs && cnNs.state !== 'miss'); - const color = litBar ? (cnNs.color || hwState.STRING_BRIGHT[cn.s] || hwState.STRING_COLORS[cn.s] || '#888') : (hwState.STRING_COLORS[cn.s] || '#888'); - const dark = litBar ? (hwState.STRING_COLORS[cn.s] || '#666') : (hwState.STRING_DIM[cn.s] || '#222'); - const barH = sz; - const barLeft = fretX(hwState, frameLeftFret, p.scale, W); - const barRight = fretX(hwState, frameRightFret, p.scale, W); - hwState.ctx.fillStyle = dark; - roundRect(hwState.ctx, barLeft - 1, ny - barH / 2 - 1, barRight - barLeft + 2, barH + 2, 3); - hwState.ctx.fill(); - hwState.ctx.fillStyle = color; - roundRect(hwState.ctx, barLeft, ny - barH / 2, barRight - barLeft, barH, 2); - hwState.ctx.fill(); - _paintGemGlow(hwState, (barLeft + barRight) / 2, ny, barH * 0.5, cn.s, cnNs); - const fontSize = Math.max(8, sz * 0.5) | 0; - hwState.ctx.fillStyle = '#fff'; - hwState.ctx.font = `bold ${fontSize}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'middle'; - fillTextReadable(hwState, '0', (barLeft + barRight) / 2, ny); - } else { - drawNote(W, H, x, ny, p.scale, cn.s, cn.f, { ...cn, chord: true }, cnNs); - } - - // Nullish-coalesce (??) rather than `||`: undefined / null - // from missing bend data still maps to 0 (matches historic - // encoding — old code shipped `entry.bn = cn.bn || 0`), but - // NaN stays NaN so it fails BOTH the strict-equality branch - // below and the `> 0` branch — keeping bad data out of the - // unbent connector set rather than silently classifying it - // as unbent. - const cnBn = cn.bn ?? 0; - const entry = { s: cn.s, f: cn.f, bn: cnBn, x, y: ny, scale: p.scale }; - if (cnBn > 0) bent.push(entry); - else if (cnBn === 0) unbent.push(entry); - } - - // Unison bend within chord — bent / unbent classified inline above. - if (bent.length > 0 && unbent.length > 0 && sz >= 14) { - for (const bn of bent) { - let closest = unbent[0]; - for (const ub of unbent) { - if (Math.abs(ub.s - bn.s) < Math.abs(closest.s - bn.s)) closest = ub; - } - const x1 = bn.x, y1 = bn.y; - const x2 = closest.x, y2 = closest.y; - const midX = (x1 + x2) / 2 + sz * 0.5; - const midY = (y1 + y2) / 2; - - hwState.ctx.save(); - hwState.ctx.strokeStyle = '#60d0ff'; - hwState.ctx.lineWidth = Math.max(2, sz / 12); - hwState.ctx.setLineDash([4, 4]); - hwState.ctx.beginPath(); - hwState.ctx.moveTo(x1, y1); - hwState.ctx.quadraticCurveTo(midX, midY, x2, y2); - hwState.ctx.stroke(); - hwState.ctx.setLineDash([]); - hwState.ctx.restore(); - - const labelSz = Math.max(10, sz * 0.3) | 0; - hwState.ctx.fillStyle = '#60d0ff'; - hwState.ctx.font = `bold ${labelSz}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'middle'; - const cpX = (x1 + 2 * midX + x2) / 4; - const cpY = (y1 + 2 * midY + y2) / 4; - fillTextReadable(hwState, 'U', cpX + sz * 0.3, cpY); - } - } - } - } - function drawFretNumbers(W, H) { const y = H * 0.97; const pad = 3; @@ -2269,181 +1395,6 @@ function createHighway() { } } - // ── Helpers ─────────────────────────────────────────────────────────── - function drawLyrics(W, H) { - if (!hwState.lyrics.length) return; - - const fontSize = Math.max(18, H * 0.028) | 0; - const lineY = H * 0.04; - - // Vocal markers: a trailing "-" means the syllable joins the - // next one into a single word (no space); a trailing "+" marks the end - // of an authored line. Build a flat list of authored lines so we can - // cap rendering to a 2-line rolling window (current + upcoming). - if (!hwState.lyrics._lines) { - const lines = []; - let line = null, word = null; - - const flushWord = () => { - if (word && word.length) line.words.push(word); - word = null; - }; - const flushLine = () => { - flushWord(); - if (line && line.words.length) lines.push(line); - line = null; - }; - - for (let i = 0; i < hwState.lyrics.length; i++) { - const l = hwState.lyrics[i]; - const raw = l.w || ''; - const endsLine = raw.endsWith('+'); - const continuesWord = raw.endsWith('-'); - - // Safety fallback: if a song has no "+" markers at all, force a - // line break on any gap > 4s so we never build a single giant line. - if (line && i > 0) { - const prev = hwState.lyrics[i - 1]; - if (l.t - (prev.t + prev.d) > 4.0) flushLine(); - } - - if (!line) line = { words: [], start: l.t, end: l.t + l.d }; - if (!word) word = []; - - word.push(l); - line.end = Math.max(line.end, l.t + l.d); - - if (!continuesWord) flushWord(); - if (endsLine) flushLine(); - } - flushLine(); - - hwState.lyrics._lines = lines; - } - - const allLines = hwState.lyrics._lines; - if (!allLines.length) return; - - // Current line = most recently started line. Before the first line has - // started, preview the first line if it's within 2s of starting. - let currentIdx = -1; - for (let i = 0; i < allLines.length; i++) { - if (allLines[i].start <= hwState.currentTime) currentIdx = i; - else break; - } - if (currentIdx === -1) { - if (allLines[0].start - hwState.currentTime > 2.0) return; - currentIdx = 0; - } - - const currentLine = allLines[currentIdx]; - const nextLine = allLines[currentIdx + 1] || null; - const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity; - - // Hide once the current line is clearly over and nothing relevant follows. - if (hwState.currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return; - - const linesToShow = [currentLine]; - if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine); - - const sylText = (s) => { - const t = s.w || ''; - return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; - }; - - hwState.ctx.font = `bold ${fontSize}px sans-serif`; - const spaceWidth = _measureLyricText(hwState.ctx, fontSize, ' '); - const maxWidth = W * 0.8; - - // Respect authored line breaks; wrap only if a line overflows maxWidth. - const rows = []; - for (const authoredLine of linesToShow) { - let row = [], rowWidth = 0; - for (const wordSyls of authoredLine.words) { - const parts = []; - let wordWidth = 0; - for (const s of wordSyls) { - const text = sylText(s); - const w = _measureLyricText(hwState.ctx, fontSize, text); - parts.push({ syl: s, text, width: w }); - wordWidth += w; - } - const advance = wordWidth + spaceWidth; - if (row.length > 0 && rowWidth + advance > maxWidth) { - rows.push(row); - row = []; rowWidth = 0; - } - row.push({ parts, advance }); - rowWidth += advance; - } - if (row.length) rows.push(row); - } - - const rowHeight = fontSize + 6; - const totalHeight = rows.length * rowHeight + 10; - let bgWidth = 0; - for (const row of rows) { - const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - if (rw > bgWidth) bgWidth = rw; - } - bgWidth = Math.min(bgWidth + 30, W * 0.85); - - hwState.ctx.fillStyle = 'rgba(0,0,0,0.7)'; - roundRect(hwState.ctx, W/2 - bgWidth/2, lineY - 4, bgWidth, totalHeight, 8); - hwState.ctx.fill(); - - hwState.ctx.textAlign = 'left'; - hwState.ctx.textBaseline = 'top'; - - for (let r = 0; r < rows.length; r++) { - const row = rows[r]; - const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; - let xPos = W/2 - rowWidth/2; - const yPos = lineY + r * rowHeight + 2; - - for (const w of row) { - for (const part of w.parts) { - const l = part.syl; - const isActive = hwState.currentTime >= l.t && hwState.currentTime < l.t + l.d; - const isPast = hwState.currentTime >= l.t + l.d; - - if (isActive) { - hwState.ctx.fillStyle = '#4ae0ff'; - hwState.ctx.font = `bold ${fontSize}px sans-serif`; - } else if (isPast) { - hwState.ctx.fillStyle = '#8899aa'; - hwState.ctx.font = `normal ${fontSize}px sans-serif`; - } else { - hwState.ctx.fillStyle = '#556677'; - hwState.ctx.font = `normal ${fontSize}px sans-serif`; - } - - hwState.ctx.fillText(part.text, xPos, yPos); - xPos += part.width; - } - xPos += spaceWidth; - } - } - } - - function bsearch(arr, time) { - let lo = 0, hi = arr.length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (arr[mid].t < time) lo = mid + 1; - else hi = mid; - } - return lo; - } - function bsearchChords(arr, time) { - let lo = 0, hi = arr.length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (arr[mid].t < time) lo = mid + 1; - else hi = mid; - } - return lo; - } // Lower-bound binary search for `.time`-keyed arrays (beats, anchors, // sections) — bsearch/bsearchChords key on `.t` and would compare // against undefined here. Exposed to custom viz as @@ -2464,220 +1415,12 @@ function createHighway() { function _resetChordRenderState() { hwState._lastChordOnFretLine = null; hwState._chordFretLineNotes = []; - _frameMismatchWarned.clear(); + hwState._frameMismatchWarned.clear(); hwState._chordRenderCacheSrc = null; hwState._chordRenderCacheInverted = null; hwState._chordRenderCacheTemplates = null; } - // True if a chord note carries per-strum technique data (bend, - // hammer/pull/tap, slide, palm-mute, vibrato, tremolo, accent, harmonic, pinch - // harmonic, dead note). drawNote shows these in 3D (`ac` accent is a brighter - // gem instead of a glyph there). Alternate render paths (repeat box, - // open-string-in-chord wide bar) - // bypass drawNote and so must fall back to the full path whenever a - // technique flag is present, otherwise authored cues vanish silently. - function _noteHasTechniqueFlags(n) { - if (n.bn || n.ho || n.po || n.tp || n.pm || n.vb || n.tr || n.ac || n.hm || n.hp || n.mt || n.fhm) return true; - if (typeof n.sl === 'number' && n.sl >= 0) return true; - return false; - } - function _chordHasTechniqueFlags(ch) { - const notes = ch.notes; - for (let i = 0; i < notes.length; i++) { - if (_noteHasTechniqueFlags(notes[i])) return true; - } - return false; - } - - // Template lookup: returns helpers that classify a chord note's fret - // against its template. Open = template fret 0 (regardless of cn.f). - function getChordTemplateInfo(chordId, chordTemplates) { - const tmpl = chordTemplates[chordId]; - const tmplFrets = tmpl && tmpl.frets ? tmpl.frets : []; - const getTemplateFret = (cn) => cn.s < tmplFrets.length ? tmplFrets[cn.s] : cn.f; - const isOpen = (cn) => getTemplateFret(cn) === 0; - return { tmpl, tmplFrets, getTemplateFret, isOpen }; - } - - // Build _chordRenderInfo for every chord in `src` if the cache is stale. - // Two passes over the array: chain bounds, then base-fret resolution - // (which can read previous chord's cached baseFret). - function _ensureChordRenderCache(src) { - const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates; - if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return; - hwState._chordRenderCacheSrc = src; - hwState._chordRenderCacheInverted = hwState._inverted; - hwState._chordRenderCacheTemplates = hwState.chordTemplates; - // Templates feed isOpen() — when they land after `chords`, - // _updateFretLinePreview's stashed open/non-open classification - // for the currently-active chord is also stale. It only refreshes - // on the next chord transition, so force a refresh here. - if (templatesChanged) { - hwState._lastChordOnFretLine = null; - hwState._chordFretLineNotes = []; - // Also clear the once-per-chord-id frame-mismatch warner — - // a chord ID warned against stale (missing/empty) templates - // would otherwise never be re-validated against the - // corrected templates that just landed. - _frameMismatchWarned.clear(); - } - - // Pass 1: walk forward, marking chain index / length / isFull on a - // per-chord WeakMap entry. A chain breaks when the next chord has a - // different id OR the time gap is >= CHAIN_GAP_THRESHOLD. - // Chords that carry per-strum technique flags (bend / palm-mute / - // hammer / pull / tap / slide / vibrato / tremolo / accent / harmonic / mute) - // never collapse to a repeat box — those cues are authored on each - // strum and must stay visible. - let chainStart = 0; - for (let i = 0; i <= src.length; i++) { - const breakHere = (i === src.length) || - (i > chainStart && (src[i].id !== src[i - 1].id || - Math.abs(src[i].t - src[i - 1].t) >= CHAIN_GAP_THRESHOLD)); - if (breakHere && i > chainStart) { - const len = i - chainStart; - for (let k = chainStart; k < i; k++) { - const chainIndex = k - chainStart; - const hasTechniques = _chordHasTechniqueFlags(src[k]); - _chordRenderInfo.set(src[k], { - chainIndex, - chainLen: len, - isFull: len < CHAIN_RENDER_FULL_MAX || chainIndex === 0 || hasTechniques, - baseFret: 0, // filled in pass 2 - sortedNotes: null, // ↓ all filled in pass 2 — cached to skip - nonZeroNotes: null, // per-frame sort/filter/min/max in drawChords. - nonZeroFrets: null, - allMuted: false, - hasMultipleNotes: false, - }); - } - chainStart = i; - } - } - - // Pass 2: resolve baseFret. Fretted chords use their own lowest - // non-open fret; chained same-id chords inherit from the previous - // entry; open-only / muted chords with a different-id predecessor - // inherit that predecessor's frame too. The walk is forward so - // prev's cached value is always present when we read it. - for (let i = 0; i < src.length; i++) { - const ch = src[i]; - const info = _chordRenderInfo.get(ch); - const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); - const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s); - const nonZero = sortedNotes.filter(cn => !isOpen(cn)); - const nonZeroFrets = nonZero.map(cn => cn.f); - if (nonZero.length >= 1) { - let minF = nonZeroFrets[0]; - for (let j = 1; j < nonZeroFrets.length; j++) if (nonZeroFrets[j] < minF) minF = nonZeroFrets[j]; - info.baseFret = minF; - } else if (i > 0) { - const prevInfo = _chordRenderInfo.get(src[i - 1]); - info.baseFret = prevInfo ? prevInfo.baseFret : 0; - } else { - info.baseFret = 0; - } - info.sortedNotes = sortedNotes; - info.nonZeroNotes = nonZero; - info.nonZeroFrets = nonZeroFrets; - info.hasMultipleNotes = sortedNotes.length >= 2; - let allMuted = sortedNotes.length > 0; - if (allMuted) { - for (let j = 0; j < sortedNotes.length; j++) { - if (!(sortedNotes[j].mt || sortedNotes[j].fhm)) { allMuted = false; break; } - } - } - info.allMuted = allMuted; - } - } - - // Compute the on-screen box for a chord (used by both muted and repeat - // box renderings). Box height tracks the per-string note positions; box - // width spans the CHORD_FRAME_FRETS frame anchored at info.baseFret. - function _computeChordBox(p, H, W, sorted, sz, actualSpread, baseFret) { - const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); - const yCenter = p.y * H; - const boxTop = yCenter - actualTotalH / 2 - sz * 0.5; - const boxBottom = boxTop + Math.max(sz, actualTotalH + sz); - const boxX = fretX(hwState, baseFret, p.scale, W); - const boxW = fretX(hwState, baseFret + CHORD_FRAME_FRETS, p.scale, W) - boxX; - return { boxX, boxW, boxTop, boxH: boxBottom - boxTop }; - } - - // Search [lo, hi) for the chord we should preview on the static fret - // line. Prefer the chord nearest the strum line that's within - // [target - before, target + after]; if none match, fall back to the - // first visible chord. Updates _lastChordOnFretLine / _chordFretLineNotes - // only when the active chord changes (lets the preview persist while a - // chord is held). - function _updateFretLinePreview(src, lo, hi) { - const targetTime = hwState.currentTime + FRETLINE_TARGET_OFFSET; - let activeChord = null; - let activeNotesOnFret = []; - let bestChordTime = -Infinity; - - for (let i = lo; i < hi; i++) { - const ch = src[i]; - if (ch.t >= targetTime - FRETLINE_WINDOW_BEFORE && - ch.t < targetTime + FRETLINE_WINDOW_AFTER && - ch.t > bestChordTime) { - bestChordTime = ch.t; - activeChord = ch; - const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); - const nonZero = ch.notes.filter(cn => !isOpen(cn)); - activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; - } - } - - if (activeChord === null) { - for (let i = lo; i < hi; i++) { - const ch = src[i]; - const p = project(ch.t - hwState.currentTime); - if (!p) continue; - activeChord = ch; - const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); - const nonZero = ch.notes.filter(cn => !isOpen(cn)); - activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; - break; - } - } - - // Compare by chord OBJECT identity rather than .id — two strums of - // the same chord template are different objects, so a chain like - // (G normal) → (G all-muted) refreshes the preview instead of - // leaving the first strum's fingerings stuck on the fret line. - if (activeChord !== hwState._lastChordOnFretLine) { - hwState._chordFretLineNotes = activeNotesOnFret; - hwState._lastChordOnFretLine = activeChord; - } - } - - function _drawFretLineChordPreview(W, H) { - if (hwState._chordFretLineNotes.length === 0) return; - const strTop = H * 0.83; - const strBot = H * 0.95; - // Scale glyphs with H so preview stays proportionate at any - // resolution / renderScale. Constants picked to match the prior - // hardcoded 30px diameter / 24px font at H=900. - const noteSize = Math.max(14, H * 0.033); - const fontSize = Math.max(11, H * 0.027) | 0; - hwState.ctx.font = `bold ${fontSize}px sans-serif`; - hwState.ctx.textAlign = 'center'; - hwState.ctx.textBaseline = 'middle'; - for (const cn of hwState._chordFretLineNotes) { - const yi = hwState._inverted ? 5 - cn.s : cn.s; - const syl = strTop + (yi / 5) * (strBot - strTop); - const fretXPos = fretX(hwState, cn.f, 1, W); - hwState.ctx.fillStyle = hwState.STRING_COLORS[cn.s] || '#888'; - hwState.ctx.beginPath(); - hwState.ctx.arc(fretXPos, syl, noteSize / 2, 0, Math.PI * 2); - hwState.ctx.fill(); - hwState.ctx.fillStyle = '#fff'; - fillTextReadable(hwState, String(cn.f), fretXPos, syl); - } - } - // Rebuild the mastery-filtered note/chord arrays from _phrases + // _mastery. Called on `ready` and on every setMastery(). When // _phrases is null (slider-disabled source), we clear the filtered diff --git a/static/js/highway-draw.js b/static/js/highway-draw.js new file mode 100644 index 0000000..b93517e --- /dev/null +++ b/static/js/highway-draw.js @@ -0,0 +1,1303 @@ +// The 2D highway's DRAWING LAYER: notes, sustains, chords, strum groups, unison bends and +// lyrics — everything the default renderer paints on the canvas each frame. +// +// hwState is a PARAMETER on every function that needs it, never an import. createHighway() is a +// FACTORY (the constitution publishes window.createHighway so a plugin can build a second +// highway for its own panel), so a module-level singleton would let two panels share one clock, +// one palette, one set of caches — silently, with nothing throwing. +// +// THREE PER-INSTANCE CACHES CAME WITH THIS SLICE, and they are the reason it needed care: +// +// _frameMismatchWarned a warn-once Set of chord ids (feedBack#88) +// _chordRenderInfo a WeakMap of chord -> chain info +// _lyricMeasureCache Map> +// +// All three are MUTATED. Left at module scope they would be shared across panels — one +// highway's lyric widths and chord chains stomping another's. They now live on hwState, which +// is exactly what hwState is for. +// +// The shimmer LUT went the OTHER way, to module scope in ./highway-geometry.js: it is a +// deterministic xorshift table, identical for every instance, so sharing it is not merely safe +// but better — built once for the page rather than once per panel. Mutability, not location, +// is what decides. +import { + _paintGemGlow, _noteState, fillTextReadable, fretX, +} from './highway-state-primitives.js'; +import { + _shimmerNoise, bnvNormalizedPoints, chordHarmonyLabels, project, roundRect, + teachingDegreeLabel, teachingFingerLabel, +} from './highway-geometry.js'; +import { + BG, CHAIN_GAP_THRESHOLD, CHAIN_RENDER_FULL_MAX, CHORD_FRAME_FRETS, MUTE_BOX_BAR, + MUTE_BOX_STROKE, REPEAT_BOX_BAR, REPEAT_BOX_FILL, VISIBLE_SECONDS, + FRETLINE_TARGET_OFFSET, FRETLINE_WINDOW_AFTER, FRETLINE_WINDOW_BEFORE, + _LYRIC_MEASURE_INNER_MAX, _LYRIC_MEASURE_OUTER_MAX, +} from './highway-constants.js'; + +export function _measureLyricText(hwState, c, fontSize, text) { + let inner = hwState._lyricMeasureCache.get(fontSize); + if (inner === undefined) { + if (hwState._lyricMeasureCache.size >= _LYRIC_MEASURE_OUTER_MAX) hwState._lyricMeasureCache.clear(); + inner = new Map(); + hwState._lyricMeasureCache.set(fontSize, inner); + } + let w = inner.get(text); + if (w === undefined) { + if (inner.size >= _LYRIC_MEASURE_INNER_MAX) inner.clear(); + w = c.measureText(text).width; + inner.set(text, w); + } + return w; +} + +export function strumGroupBuckets(items) { + if (!Array.isArray(items)) return []; + const order = []; + const byKey = new Map(); + for (const it of items) { + const ch = it && Number.isInteger(it.ch) ? it.ch : -1; + if (ch < 0) continue; + if (!byKey.has(ch)) { byKey.set(ch, []); order.push(ch); } + byKey.get(ch).push(it); + } + return order.map(k => byKey.get(k)).filter(g => g.length >= 2); +} + +export function drawNote(hwState, W, H, x, y, scale, string, fret, opts, ns) { + // ns (feedBack#254): normalized judgment state from _noteState, + // or null/undefined. `lit` means render the gem in the bright + // string colour with an additive halo; a miss gets a faint red + // wash instead. ns absent → byte-for-byte the original render. + const lit = !!(ns && ns.state !== 'miss'); + const isHarmonic = opts?.hm || opts?.hp || false; + const isPinchHarmonic = opts?.hp || false; + const isChord = opts?.chord || false; + const bend = opts?.bn || 0; + const slide = opts?.sl ?? -1; // pitched slide-to fret (-1 = none; 0 = slide to open) + const slu = opts?.slu ?? -1; // unpitched slide-to fret (-1 = none) + const hammerOn = opts?.ho || false; + const pullOff = opts?.po || false; + const tap = opts?.tp || false; + const palmMute = opts?.pm || false; + const tremolo = opts?.tr || false; + const accent = opts?.ac || false; + const sz = Math.max(12, 80 * scale * (H / 900)); + const half = sz / 2; + // When lit, bump the body one step brighter and the backing-glow + // one step up from STRING_DIM, so even shapes that don't get the + // _paintGemGlow halo (the open-string bar) read as "lit". + const color = lit ? (ns.color || hwState.STRING_BRIGHT[string] || hwState.STRING_COLORS[string] || '#888') : (hwState.STRING_COLORS[string] || '#888'); + const dark = lit ? (hwState.STRING_COLORS[string] || '#666') : (hwState.STRING_DIM[string] || '#222'); + + if (sz < 6) { + hwState.ctx.fillStyle = color; + hwState.ctx.beginPath(); + hwState.ctx.arc(x, y, 2, 0, Math.PI * 2); + hwState.ctx.fill(); + return; + } + + // Open string: wide bar spanning the highway (only for standalone notes) + if (fret === 0 && !isChord) { + const hw = W * 0.26 * scale; + const barH = Math.max(6, sz * 0.45); + // Shadow + hwState.ctx.fillStyle = dark; + roundRect(hwState.ctx, W/2 - hw - 1, y - barH/2 - 1, hw * 2 + 2, barH + 2, 3); + hwState.ctx.fill(); + // Body + hwState.ctx.fillStyle = color; + roundRect(hwState.ctx, W/2 - hw, y - barH/2, hw * 2, barH, 2); + hwState.ctx.fill(); + // Judgment glow (feedBack#254) — central halo on the bar. + // _paintGemGlow takes a half-extent; barH is the full bar height. + _paintGemGlow(hwState, W/2, y, barH * 0.5, string, ns); + // "0" label + const fontSize = Math.max(8, sz * 0.5) | 0; + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${fontSize}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'middle'; + fillTextReadable(hwState, '0', W/2, y); + + // Technique labels on open strings — PM, H/P/T, tremolo, and + // accent markers are all meaningful on fret 0. Bend and slide + // are omitted because they reference a fret position that the + // centered bar doesn't visually convey. Matches the sz<14 gate + // the fretted path uses so labels don't render on tiny bars. + // Fixes #21. + if (sz >= 14) { + // H / P / T above + if (hammerOn || pullOff || tap) { + const label = tap ? 'T' : (hammerOn ? 'H' : 'P'); + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'bottom'; + fillTextReadable(hwState, label, W/2, y - barH/2 - 4); + } + // PM below + if (palmMute) { + hwState.ctx.fillStyle = '#aaa'; + hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'top'; + fillTextReadable(hwState, 'PM', W/2, y + barH/2 + 2); + } + // Tremolo (wavy line above) + if (tremolo) { + const ty = y - barH/2 - 6; + hwState.ctx.strokeStyle = '#ff0'; + hwState.ctx.lineWidth = 1.5; + hwState.ctx.beginPath(); + for (let i = -3; i <= 3; i++) { + const wx = W/2 + i * sz * 0.08; + const wy = ty + Math.sin(i * 2) * 3; + if (i === -3) hwState.ctx.moveTo(wx, wy); + else hwState.ctx.lineTo(wx, wy); + } + hwState.ctx.stroke(); + } + // Accent caret above + if (accent) { + const ay2 = y - barH/2 - 4; + hwState.ctx.strokeStyle = '#fff'; + hwState.ctx.lineWidth = 2; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(W/2 - sz * 0.2, ay2 + 3); + hwState.ctx.lineTo(W/2, ay2 - 2); + hwState.ctx.lineTo(W/2 + sz * 0.2, ay2 + 3); + hwState.ctx.stroke(); + } + } + return; + } + + if (isHarmonic) { + // Diamond shape for harmonics + const dh = half * 1.15; + // Glow + hwState.ctx.fillStyle = dark; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x, y - dh - 3); hwState.ctx.lineTo(x + half + 3, y); + hwState.ctx.lineTo(x, y + dh + 3); hwState.ctx.lineTo(x - half - 3, y); + hwState.ctx.closePath(); hwState.ctx.fill(); + // Body + hwState.ctx.fillStyle = color; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x, y - dh); hwState.ctx.lineTo(x + half, y); + hwState.ctx.lineTo(x, y + dh); hwState.ctx.lineTo(x - half, y); + hwState.ctx.closePath(); hwState.ctx.fill(); + // Bright outline + hwState.ctx.strokeStyle = hwState.STRING_BRIGHT[string] || '#fff'; + hwState.ctx.lineWidth = 2; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x, y - dh); hwState.ctx.lineTo(x + half, y); + hwState.ctx.lineTo(x, y + dh); hwState.ctx.lineTo(x - half, y); + hwState.ctx.closePath(); hwState.ctx.stroke(); + // PH label for pinch harmonics + if (isPinchHarmonic && sz >= 14) { + hwState.ctx.fillStyle = '#ff0'; + hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'top'; + fillTextReadable(hwState, 'PH', x, y + dh + 2); + } + } else { + // Glow + hwState.ctx.fillStyle = dark; + roundRect(hwState.ctx, x - half - 4, y - half - 4, sz + 8, sz + 8, sz / 3); + hwState.ctx.fill(); + // Body + hwState.ctx.fillStyle = color; + roundRect(hwState.ctx, x - half, y - half, sz, sz, sz / 5); + hwState.ctx.fill(); + } + + // Judgment glow (feedBack#254) — additive halo for a correct + // hit / held sustain, faint red wash for a miss. Drawn before + // the fret number so the number stays legible on top. + _paintGemGlow(hwState, x, y, isHarmonic ? half * 1.2 : half, string, ns); + + // Fret number + const fontSize = Math.max(10, sz * 0.5) | 0; + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${fontSize}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'middle'; + fillTextReadable(hwState, String(fret), x, y); + + // Bend notation + if (bend && bend > 0 && sz >= 12) { + const lw = Math.max(2, sz / 10); + const ay = y - half - 4; + // px above the gem for a bend of `v` semitones (shared by the + // curve contour and the scalar-arrow fallback). + const hOf = (v) => sz * 0.55 * Math.min(Math.max(v, 0), 2); + const bnv = Array.isArray(opts?.bnv) ? opts.bnv : null; + + hwState.ctx.strokeStyle = '#fff'; + hwState.ctx.lineWidth = lw; + + let labelTopY; // y of the highest drawn point, for the label + if (bnv && bnv.length >= 2) { + // Bend curve (§6.2.1): trace the real shape as a contour above + // the gem (round-trip rises then falls, pre-bend starts high, + // release descends, …) — `bt` is implicit in the point shape. + const pts = bnvNormalizedPoints(bnv, opts?.sus); + const gw = sz * 0.6; + const x0 = x - gw / 2; + hwState.ctx.beginPath(); + pts.forEach((pt, i) => { + const px = x0 + pt.x * gw; + const py = ay - hOf(pt.v); + if (i === 0) hwState.ctx.moveTo(px, py); else hwState.ctx.lineTo(px, py); + }); + hwState.ctx.stroke(); + // Arrowhead only when the gesture ends rising (plain bend / + // pre-bend); round-trip and release finish heading down. + const a = pts[pts.length - 2], b = pts[pts.length - 1]; + if (b.v > a.v + 0.05) { + const tipX = x0 + b.x * gw, tipY = ay - hOf(b.v); + hwState.ctx.beginPath(); + hwState.ctx.moveTo(tipX - sz * 0.1, tipY + sz * 0.12); + hwState.ctx.lineTo(tipX, tipY); + hwState.ctx.lineTo(tipX + sz * 0.1, tipY + sz * 0.12); + hwState.ctx.stroke(); + } + labelTopY = ay - hOf(Math.max(...pts.map(p => p.v))); + } else { + // Fallback: single curved arrow up to the scalar peak. + const arrowH = hOf(bend); // taller for bigger bends + const tipY = ay - arrowH; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x, ay); + hwState.ctx.quadraticCurveTo(x + sz * 0.2, ay - arrowH * 0.5, x, tipY); + hwState.ctx.stroke(); + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x - sz * 0.12, tipY + sz * 0.12); + hwState.ctx.lineTo(x, tipY); + hwState.ctx.lineTo(x + sz * 0.12, tipY + sz * 0.12); + hwState.ctx.stroke(); + labelTopY = tipY; + } + + // Bend label: peak magnitude — "full", "1/2", "1 1/2", "2" + let label; + if (bend === 0.5) label = '½'; + else if (bend === 1) label = 'full'; + else if (bend === 1.5) label = '1½'; + else if (bend === 2) label = '2'; + else label = bend.toFixed(1); + + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${Math.max(9, sz * 0.28) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'bottom'; + fillTextReadable(hwState, label, x, labelTopY - 2); + } + + if (sz < 14) return; // Skip small technique labels + + // Teaching marks (§6.2.2) — display only, never grading. The fret-hand + // finger (fg) renders by default as a small numeral hugging the gem's + // right edge (T = thumb, 1..4), hideable via the finger-hints toggle; the + // scale degree (sd) is opt-in and sits on the left edge so the two never + // collide with the centred fret number. + const fgLabel = hwState._showFingerHints ? teachingFingerLabel(opts?.fg) : ''; + if (fgLabel) { + hwState.ctx.fillStyle = '#7fd1ff'; + hwState.ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; + hwState.ctx.textAlign = 'left'; + hwState.ctx.textBaseline = 'middle'; + fillTextReadable(hwState, fgLabel, x + half + 2, y + half * 0.5); + } + if (hwState._showTeachingMarks) { + const sdLabel = teachingDegreeLabel(opts?.sd); + if (sdLabel) { + hwState.ctx.fillStyle = '#ffcc66'; + hwState.ctx.font = `bold ${Math.max(8, sz * 0.26) | 0}px sans-serif`; + hwState.ctx.textAlign = 'right'; + hwState.ctx.textBaseline = 'middle'; + fillTextReadable(hwState, sdLabel, x - half - 2, y + half * 0.5); + } + } + + // Slide indicator (diagonal arrow). Pitched (sl) draws a solid arrow to + // the target fret; unpitched (slu) draws a dashed diagonal with no + // arrowhead (no definite target pitch). The two are mutually exclusive + // in the data; the 3D highway makes the same pitched/unpitched split. + if (slide >= 0 || slu >= 0) { + const pitched = slide >= 0; + const target = pitched ? slide : slu; + const dir = target > fret ? -1 : 1; // up or down the neck; mirror handles lefty + hwState.ctx.strokeStyle = '#fff'; + hwState.ctx.lineWidth = Math.max(2, sz / 10); + if (!pitched) hwState.ctx.setLineDash([Math.max(2, sz / 8), Math.max(2, sz / 8)]); + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x - sz * 0.3, y + dir * sz * 0.3); + hwState.ctx.lineTo(x + sz * 0.3, y - dir * sz * 0.3); + hwState.ctx.stroke(); + if (!pitched) hwState.ctx.setLineDash([]); + // Arrowhead only for a pitched slide (definite target pitch). + if (pitched) { + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x + sz * 0.3, y - dir * sz * 0.3); + hwState.ctx.lineTo(x + sz * 0.15, y - dir * sz * 0.15); + hwState.ctx.stroke(); + } + } + + // H/P/T label above note + if (hammerOn || pullOff || tap) { + const label = tap ? 'T' : (hammerOn ? 'H' : 'P'); + const ly = y - half - (bend > 0 ? sz * 0.6 : 4); + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${Math.max(9, sz * 0.3) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'bottom'; + fillTextReadable(hwState, label, x, ly); + } + + // Palm mute (PM below note) + if (palmMute) { + hwState.ctx.fillStyle = '#aaa'; + hwState.ctx.font = `bold ${Math.max(8, sz * 0.25) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'top'; + fillTextReadable(hwState, 'PM', x, y + half + 2); + } + + // Tremolo (wavy line above) + if (tremolo) { + const ty = y - half - (bend > 0 ? sz * 0.7 : 6); + hwState.ctx.strokeStyle = '#ff0'; + hwState.ctx.lineWidth = 1.5; + hwState.ctx.beginPath(); + for (let i = -3; i <= 3; i++) { + const wx = x + i * sz * 0.08; + const wy = ty + Math.sin(i * 2) * 3; + if (i === -3) hwState.ctx.moveTo(wx, wy); + else hwState.ctx.lineTo(wx, wy); + } + hwState.ctx.stroke(); + } + + // Accent (> marker) + if (accent) { + const ay2 = y - half - 4; + hwState.ctx.strokeStyle = '#fff'; + hwState.ctx.lineWidth = 2; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x - sz * 0.2, ay2 + 3); + hwState.ctx.lineTo(x, ay2 - 2); + hwState.ctx.lineTo(x + sz * 0.2, ay2 + 3); + hwState.ctx.stroke(); + } +} + +export function drawSustains(hwState, W, H) { + // Same master-difficulty fallback as drawNotes/drawChords — + // without this, sustain bars for filtered-out notes would + // still render, leaving orphan rectangles where no note head + // is drawn. + const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; + for (const n of src) { + if (n.sus <= 0.01) continue; + const end = n.t + n.sus; + if (end < hwState.currentTime || n.t > hwState.currentTime + VISIBLE_SECONDS) continue; + + const t0 = Math.max(n.t - hwState.currentTime, 0); + const t1 = Math.min(end - hwState.currentTime, VISIBLE_SECONDS); + if (t0 >= t1) continue; + + const p0 = project(t0), p1 = project(t1); + if (!p0 || !p1) continue; + + const x0 = fretX(hwState, n.f, p0.scale, W); + const x1 = fretX(hwState, n.f, p1.scale, W); + const sw0 = Math.max(2, 6 * p0.scale); + const sw1 = Math.max(2, 6 * p1.scale); + + // feedBack#254 — a sustain that's currently being held + // correctly "sizzles" in the bright string colour (glow + + // flickering brightness + a crackling current down the + // middle); otherwise the usual dim trail. A miss is left dim + // (the gem / overlay marks the miss; a red trail would be + // noisy). Skip the lookup entirely when no provider is set — + // zero cost in the hot loop for the common case. + const ns = hwState._noteStateProvider ? _noteState(hwState, n, n.t) : null; + const litTrail = !!(ns && ns.state !== 'miss'); + const y0 = p0.y * H, y1 = p1.y * H; + if (litTrail) { + const a = ns.alpha; + const col = ns.color || hwState.STRING_BRIGHT[n.s] || hwState.STRING_COLORS[n.s] || '#666'; + // Per-note seed so neighbouring sustains shimmer + // independently. Math.floor(n.t * 60) is stable across + // frames yet drifts on song progression; combined with + // _frameIdx + n.s it gives a non-correlated walk through + // the LUT, matching the original visual intent + // (feedBack#254 comment above). + const seedBase = (hwState._frameIdx + n.s + ((n.t * 60) | 0)) | 0; + hwState.ctx.save(); + hwState.ctx.fillStyle = col; + // Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with + // the blurred DEVICE-pixel area, and a held sustain's trail can + // span half the (DPR-scaled) canvas — profiling the "stutters + // while playing" report put this per-frame blur pass at the top + // exactly while a sustain is held. Three inflated low-alpha + // fills of the same quad read as the same soft glow at a flat, + // area-independent cost. The shimmer LUT still drives the + // per-frame size/brightness flicker (feedBack#254 intent). + const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a; + const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17)); + const fillTrail = (inflate) => { + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x0 - sw0 - inflate, y0); + hwState.ctx.lineTo(x0 + sw0 + inflate, y0); + hwState.ctx.lineTo(x1 + sw1 + inflate, y1); + hwState.ctx.lineTo(x1 - sw1 - inflate, y1); + hwState.ctx.fill(); + }; + hwState.ctx.globalAlpha = baseA * 0.22; + fillTrail(glowPx); + hwState.ctx.globalAlpha = baseA * 0.4; + fillTrail(glowPx * 0.45); + hwState.ctx.globalAlpha = baseA; + fillTrail(0); + // Crackling "current" — a jittery white core line down + // the trail, re-randomised each frame. + hwState.ctx.globalCompositeOperation = 'lighter'; + hwState.ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31)); + hwState.ctx.strokeStyle = '#ffffff'; + hwState.ctx.lineWidth = Math.max(1.5, sw0 * 0.5); + hwState.ctx.lineJoin = 'round'; + hwState.ctx.lineCap = 'round'; + hwState.ctx.beginPath(); + const segs = 7; + for (let k = 0; k <= segs; k++) { + const f = k / segs; + const jx = (k === 0 || k === segs) ? 0 : (_shimmerNoise(seedBase + 47 + k) - 0.5) * sw0 * 2.2; + const xx = x0 + (x1 - x0) * f + jx; + const yy = y0 + (y1 - y0) * f; + if (k === 0) hwState.ctx.moveTo(xx, yy); else hwState.ctx.lineTo(xx, yy); + } + hwState.ctx.stroke(); + hwState.ctx.restore(); + } else { + hwState.ctx.fillStyle = hwState.STRING_DIM[n.s] || '#333'; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x0 - sw0, y0); + hwState.ctx.lineTo(x0 + sw0, y0); + hwState.ctx.lineTo(x1 + sw1, y1); + hwState.ctx.lineTo(x1 - sw1, y1); + hwState.ctx.fill(); + } + } +} + +export function drawNotes(hwState, W, H) { + // Master-difficulty filter (feedBack#48): when the source had + // phrase-level ladder data, render from the mastery-filtered + // array. _filteredNotes stays null for slider-disabled sources + // so rendering falls through to the flat notes array unchanged. + const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; + // Binary search for visible range + const tMin = hwState.currentTime - 0.25; + const tMax = hwState.currentTime + VISIBLE_SECONDS; + let lo = bsearch(src, tMin); + let hi = bsearch(src, tMax); + + // Include sustained notes + while (lo > 0 && src[lo-1].t + src[lo-1].sus > hwState.currentTime) lo--; + + // Collect drawn positions for unison bend detection + const drawnNotes = []; + + for (let i = hi - 1; i >= lo; i--) { + const n = src[i]; + let tOff = n.t - hwState.currentTime; + + // Hold sustained notes at now line + let p; + if (tOff < -0.05 && n.sus > 0 && n.t + n.sus > hwState.currentTime) { + p = { y: 0.82, scale: 1.0 }; + } else { + p = project(tOff); + } + if (!p) continue; + + const x = fretX(hwState, n.f, p.scale, W); + drawNote(hwState, W, H, x, p.y * H, p.scale, n.s, n.f, n, hwState._noteStateProvider ? _noteState(hwState, n, n.t) : null); + drawnNotes.push({ + t: n.t, s: n.s, f: n.f, bn: n.bn || 0, x, y: p.y * H, scale: p.scale, + ch: Number.isInteger(n.ch) ? n.ch : -1, + pkd: Number.isInteger(n.pkd) ? n.pkd : -1, + }); + } + + // Draw unison bend connectors + drawUnisonBends(hwState, W, H, drawnNotes); + // Strum-group brackets (teaching mark ch, §6.2.2) — opt-in overlay. + // Scoped to standalone notes (the stream drawNotes renders); chord-note + // strum groups aren't bracketed (the editor authors ch over single-note + // selections, and chord notes already read as one simultaneous gesture). + if (hwState._showTeachingMarks) drawStrumGroups(hwState, W, H, drawnNotes); +} + +export function drawStrumGroups(hwState, W, H, drawnNotes) { + // Teaching mark (§6.2.2): notes sharing a `ch` key >= 0 are one + // strum/rake gesture. Connect each group's gems with a bracket and a + // single arrowhead whose direction comes from `pkd` (0 = down-strum, + // 1 = up-strum). Display only — never grading. + for (const group of strumGroupBuckets(drawnNotes)) { + const pts = group.slice().sort((a, b) => a.y - b.y || a.x - b.x); + const scale = pts[0].scale; + const sz = Math.max(12, 80 * scale * (H / 900)); + if (sz < 14) continue; + const pkd = (group.find(p => p.pkd === 0 || p.pkd === 1) || {}).pkd; + + hwState.ctx.save(); + hwState.ctx.strokeStyle = '#c89bff'; + hwState.ctx.lineWidth = Math.max(2, sz / 12); + hwState.ctx.lineJoin = 'round'; + hwState.ctx.beginPath(); + pts.forEach((p, i) => (i === 0 ? hwState.ctx.moveTo(p.x, p.y) : hwState.ctx.lineTo(p.x, p.y))); + hwState.ctx.stroke(); + // Arrowhead at the gesture start: down-strum (pkd 0) points toward + // the last gem, up-strum (pkd 1) toward the first. + if (pkd === 0 || pkd === 1) { + const head = pkd === 1 ? pts[0] : pts[pts.length - 1]; + const from = pkd === 1 ? pts[1] : pts[pts.length - 2]; + const dy = Math.sign(head.y - from.y) || 1; + const a = sz * 0.18; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(head.x - a, head.y - dy * a); + hwState.ctx.lineTo(head.x, head.y); + hwState.ctx.lineTo(head.x + a, head.y - dy * a); + hwState.ctx.stroke(); + } + hwState.ctx.restore(); + } +} + +export function drawUnisonBends(hwState, W, H, drawnNotes) { + // Group notes by time (within 0.01s tolerance) + const groups = []; + const used = new Set(); + for (let i = 0; i < drawnNotes.length; i++) { + if (used.has(i)) continue; + const group = [drawnNotes[i]]; + used.add(i); + for (let j = i + 1; j < drawnNotes.length; j++) { + if (used.has(j)) continue; + if (Math.abs(drawnNotes[j].t - drawnNotes[i].t) < 0.01) { + group.push(drawnNotes[j]); + used.add(j); + } + } + if (group.length >= 2) groups.push(group); + } + + for (const group of groups) { + // Find pairs: one with bend, one without (or both with different bends) + const bent = group.filter(n => n.bn > 0); + const unbent = group.filter(n => n.bn === 0); + if (bent.length === 0 || unbent.length === 0) continue; + + // Draw connector between each bent-unbent pair + for (const bn of bent) { + // Find the closest unbent note by string + let closest = unbent[0]; + for (const ub of unbent) { + if (Math.abs(ub.s - bn.s) < Math.abs(closest.s - bn.s)) closest = ub; + } + + const sz = Math.max(12, 80 * bn.scale * (H / 900)); + if (sz < 14) continue; + + // Draw a curved dashed line connecting bent note to target note + const x1 = bn.x, y1 = bn.y; + const x2 = closest.x, y2 = closest.y; + const midX = (x1 + x2) / 2 + sz * 0.5; + const midY = (y1 + y2) / 2; + + hwState.ctx.save(); + hwState.ctx.strokeStyle = '#60d0ff'; + hwState.ctx.lineWidth = Math.max(2, sz / 12); + hwState.ctx.setLineDash([4, 4]); + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x1, y1); + hwState.ctx.quadraticCurveTo(midX, midY, x2, y2); + hwState.ctx.stroke(); + hwState.ctx.setLineDash([]); + hwState.ctx.restore(); + + // "U" label at midpoint + const labelSz = Math.max(10, sz * 0.3) | 0; + hwState.ctx.fillStyle = '#60d0ff'; + hwState.ctx.font = `bold ${labelSz}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'middle'; + const cpX = (x1 + 2 * midX + x2) / 4; + const cpY = (y1 + 2 * midY + y2) / 4; + fillTextReadable(hwState, 'U', cpX + sz * 0.3, cpY); + } + } +} + +export function drawChords(hwState, W, H) { + // See drawNotes — _filteredChords is null for slider-disabled + // sources so we fall through to the flat chords array. + const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; + _ensureChordRenderCache(hwState, src); + + const tMin = hwState.currentTime - 0.25; + const tMax = hwState.currentTime + VISIBLE_SECONDS; + const lo = bsearchChords(src, tMin); + const hi = bsearchChords(src, tMax); + + _updateFretLinePreview(hwState, src, lo, hi); + _drawFretLineChordPreview(hwState, W, H); + + for (let i = hi - 1; i >= lo; i--) { + const ch = src[i]; + const p = project(ch.t - hwState.currentTime); + if (!p) continue; + + const info = hwState._chordRenderInfo.get(ch); + const { isFull, baseFret, sortedNotes: sorted, nonZeroNotes, nonZeroFrets, allMuted, hasMultipleNotes } = info; + + const sz = Math.max(10, 28 * p.scale * (H / 900)); + const spread = sz * 0.85; + const minSpread = sz + 16 * p.scale; + const actualSpread = Math.max(spread, minSpread); + const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); + + const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates); + const hasNonZero = nonZeroNotes.length >= 1; + + const frameLeftFret = baseFret; + const frameRightFret = baseFret + CHORD_FRAME_FRETS; + + // Frame validation — log once per chord id rather than every frame. + if (hasNonZero && !hwState._frameMismatchWarned.has(ch.id)) { + let notesInFrame = true; + for (let k = 0; k < nonZeroFrets.length; k++) { + const f = nonZeroFrets[k]; + if (f < frameLeftFret || f > frameRightFret) { notesInFrame = false; break; } + } + if (!notesInFrame) { + hwState._frameMismatchWarned.add(ch.id); + console.warn('Chord frame mismatch:', ch.id, { frameLeftFret, frameRightFret, nonZeroFrets }); + } + } + + // X span between fretted notes (excluding open strings) — + // single pass over cached nonZeroFrets, no spread + Math.min/max. + let xMin = null, xMax = null; + if (hasNonZero) { + xMin = Infinity; xMax = -Infinity; + for (let k = 0; k < nonZeroFrets.length; k++) { + const x = fretX(hwState, nonZeroFrets[k], p.scale, W); + if (x < xMin) xMin = x; + if (x > xMax) xMax = x; + } + } + if (allMuted) { + const { boxX, boxW, boxTop, boxH } = _computeChordBox(hwState, p, H, W, sorted, sz, actualSpread, baseFret); + + hwState.ctx.strokeStyle = MUTE_BOX_STROKE; + hwState.ctx.lineWidth = Math.max(2, sz / 6); + roundRect(hwState.ctx, boxX, boxTop, boxW, boxH, 2); + hwState.ctx.stroke(); + + hwState.ctx.fillStyle = MUTE_BOX_BAR; + hwState.ctx.fillRect(boxX, boxTop + 2, boxW, 4); + + // Gray X cross, centered in frame + const xInset = sz * 0.6; + const xStartX = boxX + xInset; + const xEndX = boxX + boxW - xInset; + hwState.ctx.beginPath(); + hwState.ctx.moveTo(xStartX, boxTop + sz * 0.5); + hwState.ctx.lineTo(xEndX, boxTop + boxH - sz * 0.5); + hwState.ctx.moveTo(xEndX, boxTop + sz * 0.5); + hwState.ctx.lineTo(xStartX, boxTop + boxH - sz * 0.5); + hwState.ctx.stroke(); + + continue; + } + + // Repeat chord (mid-chain): translucent box + bracket bar. + if (!isFull) { + const { boxX, boxW, boxTop, boxH } = _computeChordBox(hwState, p, H, W, sorted, sz, actualSpread, baseFret); + + hwState.ctx.fillStyle = REPEAT_BOX_FILL; + roundRect(hwState.ctx, boxX, boxTop, boxW, boxH, 2); + hwState.ctx.fill(); + + hwState.ctx.fillStyle = REPEAT_BOX_BAR; + hwState.ctx.fillRect(boxX, boxTop + 2, boxW, 4); + + continue; + } + + // First-in-chain (or short chain): full chord rendering. + // Bracket bar above the notes. + if (hasNonZero || sorted.length >= 2) { + const positions = (hasNonZero ? nonZeroNotes : sorted).map((cn, j) => ({ + x: fretX(hwState, cn.f, p.scale, W), + y: p.y * H - actualTotalH / 2 + j * actualSpread, + })); + const barY = positions[0].y - sz * 0.7; + const barLeft = hasNonZero ? xMin : fretX(hwState, frameLeftFret, p.scale, W); + const barRight = hasNonZero ? xMax : fretX(hwState, frameRightFret, p.scale, W); + + hwState.ctx.fillStyle = REPEAT_BOX_BAR; + hwState.ctx.lineWidth = Math.max(3, sz / 4); + roundRect(hwState.ctx, barLeft - 2, barY - 2, barRight - barLeft + 4, 4, 2); + hwState.ctx.fill(); + for (const pos of positions) { + hwState.ctx.fillRect(pos.x - 2, barY, 4, pos.y - sz / 2 - barY); + } + } + + // Chord name label + if (!ch.hd && p.scale > 0.15 && tmpl && tmpl.name) { + const labelY = hasNonZero + ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) + : (p.y * H - sz * 0.8); + const labelX = hasNonZero + ? (xMin + xMax) / 2 + : (sorted.length >= 2 + ? (fretX(hwState, frameLeftFret, p.scale, W) + fretX(hwState, frameRightFret, p.scale, W)) / 2 + : fretX(hwState, sorted[0].f, p.scale, W)); + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${Math.max(14, sz * 0.45) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'bottom'; + fillTextReadable(hwState, tmpl.name, labelX, labelY); + } + + // Harmony annotations (§6.3.1 / §6.6) — the chord's function + // (fn.rn Roman numeral) and template voicing, stacked above the + // chord name. Gated behind the teaching-marks opt-in (same overlay + // class as sd/ch) so they don't clutter the default highway. + // Display only — never grading. + if (hwState._showTeachingMarks && !ch.hd && p.scale > 0.15 && sorted.length > 0) { + const { rn, voicing, caged, guideTones } = chordHarmonyLabels( + ch.fn, tmpl && tmpl.voicing, tmpl && tmpl.caged, tmpl && tmpl.guideTones); + if (rn || voicing || caged || guideTones) { + const hx = hasNonZero + ? (xMin + xMax) / 2 + : (sorted.length >= 2 + ? (fretX(hwState, frameLeftFret, p.scale, W) + fretX(hwState, frameRightFret, p.scale, W)) / 2 + : fretX(hwState, sorted[0].f, p.scale, W)); + // Baseline = just above where the chord name sits. + const nameY = hasNonZero + ? (p.y * H - actualTotalH / 2 - sz * 0.7 - sz * 0.4) + : (p.y * H - sz * 0.8); + hwState.ctx.font = `bold ${Math.max(10, sz * 0.32) | 0}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'bottom'; + let stackY = nameY - sz * 0.5; + if (rn) { + hwState.ctx.fillStyle = '#ffcc66'; // matches the sd teaching color + fillTextReadable(hwState, rn, hx, stackY); + stackY -= sz * 0.45; + } + if (voicing) { + hwState.ctx.fillStyle = '#7fd1ff'; // matches the fg teaching color + fillTextReadable(hwState, voicing, hx, stackY); + stackY -= sz * 0.45; + } + if (caged) { + hwState.ctx.fillStyle = '#a0ffa0'; // CAGED shape teaching color + fillTextReadable(hwState, caged, hx, stackY); + stackY -= sz * 0.45; + } + if (guideTones) { + hwState.ctx.fillStyle = '#d0a0ff'; // guide-tone teaching color + fillTextReadable(hwState, guideTones, hx, stackY); + } + } + } + + // Notes — wide colored bar for open strings inside a chord, + // normal note glyph otherwise. + // Classify into bent / unbent arrays inline (was: post-filter + // chordPositions twice into bent/unbent). + const bent = []; + const unbent = []; + + for (let j = 0; j < sorted.length; j++) { + const cn = sorted[j]; + const x = fretX(hwState, cn.f, p.scale, W); + const ny = p.y * H - actualTotalH / 2 + j * actualSpread; + // feedBack#254 — per-string judgment, keyed by the + // chord's chart time (matches how note_detect stores it). + const cnNs = hwState._noteStateProvider ? _noteState(hwState, cn, ch.t) : null; + + // Open-string-in-chord wide bar — only when the note has no + // technique flags. Otherwise fall back to drawNote so PM / + // H / P / T / tremolo / accent labels still render (drawNote + // is the only path that emits those labels). + if (getTemplateFret(cn) === 0 && hasMultipleNotes && !_noteHasTechniqueFlags(cn)) { + const litBar = !!(cnNs && cnNs.state !== 'miss'); + const color = litBar ? (cnNs.color || hwState.STRING_BRIGHT[cn.s] || hwState.STRING_COLORS[cn.s] || '#888') : (hwState.STRING_COLORS[cn.s] || '#888'); + const dark = litBar ? (hwState.STRING_COLORS[cn.s] || '#666') : (hwState.STRING_DIM[cn.s] || '#222'); + const barH = sz; + const barLeft = fretX(hwState, frameLeftFret, p.scale, W); + const barRight = fretX(hwState, frameRightFret, p.scale, W); + hwState.ctx.fillStyle = dark; + roundRect(hwState.ctx, barLeft - 1, ny - barH / 2 - 1, barRight - barLeft + 2, barH + 2, 3); + hwState.ctx.fill(); + hwState.ctx.fillStyle = color; + roundRect(hwState.ctx, barLeft, ny - barH / 2, barRight - barLeft, barH, 2); + hwState.ctx.fill(); + _paintGemGlow(hwState, (barLeft + barRight) / 2, ny, barH * 0.5, cn.s, cnNs); + const fontSize = Math.max(8, sz * 0.5) | 0; + hwState.ctx.fillStyle = '#fff'; + hwState.ctx.font = `bold ${fontSize}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'middle'; + fillTextReadable(hwState, '0', (barLeft + barRight) / 2, ny); + } else { + drawNote(hwState, W, H, x, ny, p.scale, cn.s, cn.f, { ...cn, chord: true }, cnNs); + } + + // Nullish-coalesce (??) rather than `||`: undefined / null + // from missing bend data still maps to 0 (matches historic + // encoding — old code shipped `entry.bn = cn.bn || 0`), but + // NaN stays NaN so it fails BOTH the strict-equality branch + // below and the `> 0` branch — keeping bad data out of the + // unbent connector set rather than silently classifying it + // as unbent. + const cnBn = cn.bn ?? 0; + const entry = { s: cn.s, f: cn.f, bn: cnBn, x, y: ny, scale: p.scale }; + if (cnBn > 0) bent.push(entry); + else if (cnBn === 0) unbent.push(entry); + } + + // Unison bend within chord — bent / unbent classified inline above. + if (bent.length > 0 && unbent.length > 0 && sz >= 14) { + for (const bn of bent) { + let closest = unbent[0]; + for (const ub of unbent) { + if (Math.abs(ub.s - bn.s) < Math.abs(closest.s - bn.s)) closest = ub; + } + const x1 = bn.x, y1 = bn.y; + const x2 = closest.x, y2 = closest.y; + const midX = (x1 + x2) / 2 + sz * 0.5; + const midY = (y1 + y2) / 2; + + hwState.ctx.save(); + hwState.ctx.strokeStyle = '#60d0ff'; + hwState.ctx.lineWidth = Math.max(2, sz / 12); + hwState.ctx.setLineDash([4, 4]); + hwState.ctx.beginPath(); + hwState.ctx.moveTo(x1, y1); + hwState.ctx.quadraticCurveTo(midX, midY, x2, y2); + hwState.ctx.stroke(); + hwState.ctx.setLineDash([]); + hwState.ctx.restore(); + + const labelSz = Math.max(10, sz * 0.3) | 0; + hwState.ctx.fillStyle = '#60d0ff'; + hwState.ctx.font = `bold ${labelSz}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'middle'; + const cpX = (x1 + 2 * midX + x2) / 4; + const cpY = (y1 + 2 * midY + y2) / 4; + fillTextReadable(hwState, 'U', cpX + sz * 0.3, cpY); + } + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────── +export function drawLyrics(hwState, W, H) { + if (!hwState.lyrics.length) return; + + const fontSize = Math.max(18, H * 0.028) | 0; + const lineY = H * 0.04; + + // Vocal markers: a trailing "-" means the syllable joins the + // next one into a single word (no space); a trailing "+" marks the end + // of an authored line. Build a flat list of authored lines so we can + // cap rendering to a 2-line rolling window (current + upcoming). + if (!hwState.lyrics._lines) { + const lines = []; + let line = null, word = null; + + const flushWord = () => { + if (word && word.length) line.words.push(word); + word = null; + }; + const flushLine = () => { + flushWord(); + if (line && line.words.length) lines.push(line); + line = null; + }; + + for (let i = 0; i < hwState.lyrics.length; i++) { + const l = hwState.lyrics[i]; + const raw = l.w || ''; + const endsLine = raw.endsWith('+'); + const continuesWord = raw.endsWith('-'); + + // Safety fallback: if a song has no "+" markers at all, force a + // line break on any gap > 4s so we never build a single giant line. + if (line && i > 0) { + const prev = hwState.lyrics[i - 1]; + if (l.t - (prev.t + prev.d) > 4.0) flushLine(); + } + + if (!line) line = { words: [], start: l.t, end: l.t + l.d }; + if (!word) word = []; + + word.push(l); + line.end = Math.max(line.end, l.t + l.d); + + if (!continuesWord) flushWord(); + if (endsLine) flushLine(); + } + flushLine(); + + hwState.lyrics._lines = lines; + } + + const allLines = hwState.lyrics._lines; + if (!allLines.length) return; + + // Current line = most recently started line. Before the first line has + // started, preview the first line if it's within 2s of starting. + let currentIdx = -1; + for (let i = 0; i < allLines.length; i++) { + if (allLines[i].start <= hwState.currentTime) currentIdx = i; + else break; + } + if (currentIdx === -1) { + if (allLines[0].start - hwState.currentTime > 2.0) return; + currentIdx = 0; + } + + const currentLine = allLines[currentIdx]; + const nextLine = allLines[currentIdx + 1] || null; + const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity; + + // Hide once the current line is clearly over and nothing relevant follows. + if (hwState.currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return; + + const linesToShow = [currentLine]; + if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine); + + const sylText = (s) => { + const t = s.w || ''; + return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; + }; + + hwState.ctx.font = `bold ${fontSize}px sans-serif`; + const spaceWidth = _measureLyricText(hwState, hwState.ctx, fontSize, ' '); + const maxWidth = W * 0.8; + + // Respect authored line breaks; wrap only if a line overflows maxWidth. + const rows = []; + for (const authoredLine of linesToShow) { + let row = [], rowWidth = 0; + for (const wordSyls of authoredLine.words) { + const parts = []; + let wordWidth = 0; + for (const s of wordSyls) { + const text = sylText(s); + const w = _measureLyricText(hwState, hwState.ctx, fontSize, text); + parts.push({ syl: s, text, width: w }); + wordWidth += w; + } + const advance = wordWidth + spaceWidth; + if (row.length > 0 && rowWidth + advance > maxWidth) { + rows.push(row); + row = []; rowWidth = 0; + } + row.push({ parts, advance }); + rowWidth += advance; + } + if (row.length) rows.push(row); + } + + const rowHeight = fontSize + 6; + const totalHeight = rows.length * rowHeight + 10; + let bgWidth = 0; + for (const row of rows) { + const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; + if (rw > bgWidth) bgWidth = rw; + } + bgWidth = Math.min(bgWidth + 30, W * 0.85); + + hwState.ctx.fillStyle = 'rgba(0,0,0,0.7)'; + roundRect(hwState.ctx, W/2 - bgWidth/2, lineY - 4, bgWidth, totalHeight, 8); + hwState.ctx.fill(); + + hwState.ctx.textAlign = 'left'; + hwState.ctx.textBaseline = 'top'; + + for (let r = 0; r < rows.length; r++) { + const row = rows[r]; + const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth; + let xPos = W/2 - rowWidth/2; + const yPos = lineY + r * rowHeight + 2; + + for (const w of row) { + for (const part of w.parts) { + const l = part.syl; + const isActive = hwState.currentTime >= l.t && hwState.currentTime < l.t + l.d; + const isPast = hwState.currentTime >= l.t + l.d; + + if (isActive) { + hwState.ctx.fillStyle = '#4ae0ff'; + hwState.ctx.font = `bold ${fontSize}px sans-serif`; + } else if (isPast) { + hwState.ctx.fillStyle = '#8899aa'; + hwState.ctx.font = `normal ${fontSize}px sans-serif`; + } else { + hwState.ctx.fillStyle = '#556677'; + hwState.ctx.font = `normal ${fontSize}px sans-serif`; + } + + hwState.ctx.fillText(part.text, xPos, yPos); + xPos += part.width; + } + xPos += spaceWidth; + } + } +} + +export function bsearch(arr, time) { + let lo = 0, hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (arr[mid].t < time) lo = mid + 1; + else hi = mid; + } + return lo; +} + +export function bsearchChords(arr, time) { + let lo = 0, hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (arr[mid].t < time) lo = mid + 1; + else hi = mid; + } + return lo; +} + +// True if a chord note carries per-strum technique data (bend, +// hammer/pull/tap, slide, palm-mute, vibrato, tremolo, accent, harmonic, pinch +// harmonic, dead note). drawNote shows these in 3D (`ac` accent is a brighter +// gem instead of a glyph there). Alternate render paths (repeat box, +// open-string-in-chord wide bar) +// bypass drawNote and so must fall back to the full path whenever a +// technique flag is present, otherwise authored cues vanish silently. +export function _noteHasTechniqueFlags(n) { + if (n.bn || n.ho || n.po || n.tp || n.pm || n.vb || n.tr || n.ac || n.hm || n.hp || n.mt || n.fhm) return true; + if (typeof n.sl === 'number' && n.sl >= 0) return true; + return false; +} + +export function _chordHasTechniqueFlags(ch) { + const notes = ch.notes; + for (let i = 0; i < notes.length; i++) { + if (_noteHasTechniqueFlags(notes[i])) return true; + } + return false; +} + +// Template lookup: returns helpers that classify a chord note's fret +// against its template. Open = template fret 0 (regardless of cn.f). +export function getChordTemplateInfo(chordId, chordTemplates) { + const tmpl = chordTemplates[chordId]; + const tmplFrets = tmpl && tmpl.frets ? tmpl.frets : []; + const getTemplateFret = (cn) => cn.s < tmplFrets.length ? tmplFrets[cn.s] : cn.f; + const isOpen = (cn) => getTemplateFret(cn) === 0; + return { tmpl, tmplFrets, getTemplateFret, isOpen }; +} + +// Build _chordRenderInfo for every chord in `src` if the cache is stale. +// Two passes over the array: chain bounds, then base-fret resolution +// (which can read previous chord's cached baseFret). +export function _ensureChordRenderCache(hwState, src) { + const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates; + if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return; + hwState._chordRenderCacheSrc = src; + hwState._chordRenderCacheInverted = hwState._inverted; + hwState._chordRenderCacheTemplates = hwState.chordTemplates; + // Templates feed isOpen() — when they land after `chords`, + // _updateFretLinePreview's stashed open/non-open classification + // for the currently-active chord is also stale. It only refreshes + // on the next chord transition, so force a refresh here. + if (templatesChanged) { + hwState._lastChordOnFretLine = null; + hwState._chordFretLineNotes = []; + // Also clear the once-per-chord-id frame-mismatch warner — + // a chord ID warned against stale (missing/empty) templates + // would otherwise never be re-validated against the + // corrected templates that just landed. + hwState._frameMismatchWarned.clear(); + } + + // Pass 1: walk forward, marking chain index / length / isFull on a + // per-chord WeakMap entry. A chain breaks when the next chord has a + // different id OR the time gap is >= CHAIN_GAP_THRESHOLD. + // Chords that carry per-strum technique flags (bend / palm-mute / + // hammer / pull / tap / slide / vibrato / tremolo / accent / harmonic / mute) + // never collapse to a repeat box — those cues are authored on each + // strum and must stay visible. + let chainStart = 0; + for (let i = 0; i <= src.length; i++) { + const breakHere = (i === src.length) || + (i > chainStart && (src[i].id !== src[i - 1].id || + Math.abs(src[i].t - src[i - 1].t) >= CHAIN_GAP_THRESHOLD)); + if (breakHere && i > chainStart) { + const len = i - chainStart; + for (let k = chainStart; k < i; k++) { + const chainIndex = k - chainStart; + const hasTechniques = _chordHasTechniqueFlags(src[k]); + hwState._chordRenderInfo.set(src[k], { + chainIndex, + chainLen: len, + isFull: len < CHAIN_RENDER_FULL_MAX || chainIndex === 0 || hasTechniques, + baseFret: 0, // filled in pass 2 + sortedNotes: null, // ↓ all filled in pass 2 — cached to skip + nonZeroNotes: null, // per-frame sort/filter/min/max in drawChords. + nonZeroFrets: null, + allMuted: false, + hasMultipleNotes: false, + }); + } + chainStart = i; + } + } + + // Pass 2: resolve baseFret. Fretted chords use their own lowest + // non-open fret; chained same-id chords inherit from the previous + // entry; open-only / muted chords with a different-id predecessor + // inherit that predecessor's frame too. The walk is forward so + // prev's cached value is always present when we read it. + for (let i = 0; i < src.length; i++) { + const ch = src[i]; + const info = hwState._chordRenderInfo.get(ch); + const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); + const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s); + const nonZero = sortedNotes.filter(cn => !isOpen(cn)); + const nonZeroFrets = nonZero.map(cn => cn.f); + if (nonZero.length >= 1) { + let minF = nonZeroFrets[0]; + for (let j = 1; j < nonZeroFrets.length; j++) if (nonZeroFrets[j] < minF) minF = nonZeroFrets[j]; + info.baseFret = minF; + } else if (i > 0) { + const prevInfo = hwState._chordRenderInfo.get(src[i - 1]); + info.baseFret = prevInfo ? prevInfo.baseFret : 0; + } else { + info.baseFret = 0; + } + info.sortedNotes = sortedNotes; + info.nonZeroNotes = nonZero; + info.nonZeroFrets = nonZeroFrets; + info.hasMultipleNotes = sortedNotes.length >= 2; + let allMuted = sortedNotes.length > 0; + if (allMuted) { + for (let j = 0; j < sortedNotes.length; j++) { + if (!(sortedNotes[j].mt || sortedNotes[j].fhm)) { allMuted = false; break; } + } + } + info.allMuted = allMuted; + } +} + +// Compute the on-screen box for a chord (used by both muted and repeat +// box renderings). Box height tracks the per-string note positions; box +// width spans the CHORD_FRAME_FRETS frame anchored at info.baseFret. +export function _computeChordBox(hwState, p, H, W, sorted, sz, actualSpread, baseFret) { + const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); + const yCenter = p.y * H; + const boxTop = yCenter - actualTotalH / 2 - sz * 0.5; + const boxBottom = boxTop + Math.max(sz, actualTotalH + sz); + const boxX = fretX(hwState, baseFret, p.scale, W); + const boxW = fretX(hwState, baseFret + CHORD_FRAME_FRETS, p.scale, W) - boxX; + return { boxX, boxW, boxTop, boxH: boxBottom - boxTop }; +} + +// Search [lo, hi) for the chord we should preview on the static fret +// line. Prefer the chord nearest the strum line that's within +// [target - before, target + after]; if none match, fall back to the +// first visible chord. Updates _lastChordOnFretLine / _chordFretLineNotes +// only when the active chord changes (lets the preview persist while a +// chord is held). +export function _updateFretLinePreview(hwState, src, lo, hi) { + const targetTime = hwState.currentTime + FRETLINE_TARGET_OFFSET; + let activeChord = null; + let activeNotesOnFret = []; + let bestChordTime = -Infinity; + + for (let i = lo; i < hi; i++) { + const ch = src[i]; + if (ch.t >= targetTime - FRETLINE_WINDOW_BEFORE && + ch.t < targetTime + FRETLINE_WINDOW_AFTER && + ch.t > bestChordTime) { + bestChordTime = ch.t; + activeChord = ch; + const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); + const nonZero = ch.notes.filter(cn => !isOpen(cn)); + activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; + } + } + + if (activeChord === null) { + for (let i = lo; i < hi; i++) { + const ch = src[i]; + const p = project(ch.t - hwState.currentTime); + if (!p) continue; + activeChord = ch; + const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); + const nonZero = ch.notes.filter(cn => !isOpen(cn)); + activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; + break; + } + } + + // Compare by chord OBJECT identity rather than .id — two strums of + // the same chord template are different objects, so a chain like + // (G normal) → (G all-muted) refreshes the preview instead of + // leaving the first strum's fingerings stuck on the fret line. + if (activeChord !== hwState._lastChordOnFretLine) { + hwState._chordFretLineNotes = activeNotesOnFret; + hwState._lastChordOnFretLine = activeChord; + } +} + +export function _drawFretLineChordPreview(hwState, W, H) { + if (hwState._chordFretLineNotes.length === 0) return; + const strTop = H * 0.83; + const strBot = H * 0.95; + // Scale glyphs with H so preview stays proportionate at any + // resolution / renderScale. Constants picked to match the prior + // hardcoded 30px diameter / 24px font at H=900. + const noteSize = Math.max(14, H * 0.033); + const fontSize = Math.max(11, H * 0.027) | 0; + hwState.ctx.font = `bold ${fontSize}px sans-serif`; + hwState.ctx.textAlign = 'center'; + hwState.ctx.textBaseline = 'middle'; + for (const cn of hwState._chordFretLineNotes) { + const yi = hwState._inverted ? 5 - cn.s : cn.s; + const syl = strTop + (yi / 5) * (strBot - strTop); + const fretXPos = fretX(hwState, cn.f, 1, W); + hwState.ctx.fillStyle = hwState.STRING_COLORS[cn.s] || '#888'; + hwState.ctx.beginPath(); + hwState.ctx.arc(fretXPos, syl, noteSize / 2, 0, Math.PI * 2); + hwState.ctx.fill(); + hwState.ctx.fillStyle = '#fff'; + fillTextReadable(hwState, String(cn.f), fretXPos, syl); + } +} diff --git a/static/js/highway-geometry.js b/static/js/highway-geometry.js index cf1ea34..82b1bbe 100644 --- a/static/js/highway-geometry.js +++ b/static/js/highway-geometry.js @@ -15,7 +15,7 @@ // are deliberately left behind. They need an explicit hwState parameter threaded through 53 // call sites, which is a real change and belongs in its own commit, not smuggled in beside a // provably-identical move. -import { VISIBLE_SECONDS, Z_CAM, Z_MAX } from './highway-constants.js'; +import { VISIBLE_SECONDS, Z_CAM, Z_MAX, _SHIMMER_LUT_SIZE } from './highway-constants.js'; // ── Projection ─────────────────────────────────────────────────────── export function project(tOffset) { @@ -77,3 +77,27 @@ export function roundRect(ctx, x, y, w, h, r) { ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } + + +// ── The shimmer noise LUT ─────────────────────────────────────────────────────── +// +// A DETERMINISTIC xorshift table: no randomness, no state, byte-for-byte identical for every +// highway instance. Unlike the three per-instance caches that came out of the drawing layer (a +// warn-once Set, a chord WeakMap, a lyric-width Map — all MUTATED, all lifted onto hwState so +// two panels cannot stomp each other), this one is not merely SAFE to share but BETTER shared: +// built once for the page instead of once per panel. +// +// MUTABILITY, NOT LOCATION, IS WHAT DECIDES WHERE A THING BELONGS. +const _shimmerLut = new Float32Array(_SHIMMER_LUT_SIZE); +for (let i = 0; i < _SHIMMER_LUT_SIZE; i++) { + let x = (i + 1) | 0; // +1 dodges the all-zero xorshift trap + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + _shimmerLut[i] = (x >>> 0) / 4294967296; +} + +export function _shimmerNoise(seed) { + // Mask works only because _SHIMMER_LUT_SIZE is a power of two. + return _shimmerLut[(seed >>> 0) & (_SHIMMER_LUT_SIZE - 1)]; +} diff --git a/tests/js/highway_chord_render_cache.test.js b/tests/js/highway_chord_render_cache.test.js index 94f2a2c..240e763 100644 --- a/tests/js/highway_chord_render_cache.test.js +++ b/tests/js/highway_chord_render_cache.test.js @@ -19,8 +19,23 @@ const path = require('node:path'); const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js'); + +// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the +// whole set rather than re-pinning at whichever file currently holds a function — re-pinning +// just breaks again next time, and a source-shape assertion that silently stops finding its +// target is indistinguishable from one that passes. +function highwaySources() { + const root = path.join(__dirname, '..', '..'); + const jsDir = path.join(root, 'static', 'js'); + const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')]; + for (const f of fs.readdirSync(jsDir).sort()) { + if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8')); + } + return parts.join('\n'); +} + test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); // The cache key triple must include chordTemplates — without it, a // late-arriving `chord_templates` WS message leaves cached // nonZeroNotes / nonZeroFrets stale until the next chord transition. @@ -41,7 +56,7 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () = }); test('chordTemplates change resets fretline preview and frame-mismatch warner', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); // The cache-invalidation block must clear both _chordFretLineNotes // (so _updateFretLinePreview re-publishes with corrected isOpen // classification) and _frameMismatchWarned (so a chord ID warned diff --git a/tests/js/highway_note_state.test.js b/tests/js/highway_note_state.test.js index 2de3176..68ef0b3 100644 --- a/tests/js/highway_note_state.test.js +++ b/tests/js/highway_note_state.test.js @@ -36,13 +36,28 @@ function extractBlock(src, signature) { return src.slice(start, i); } + +// R3c: highway.js is being carved into modules, so its source is no longer ONE file. Read the +// whole set rather than re-pinning at whichever file currently holds a function — re-pinning +// just breaks again next time, and a source-shape assertion that silently stops finding its +// target is indistinguishable from one that passes. +function highwaySources() { + const root = path.join(__dirname, '..', '..'); + const jsDir = path.join(root, 'static', 'js'); + const parts = [fs.readFileSync(path.join(root, 'static', 'highway.js'), 'utf8')]; + for (const f of fs.readdirSync(jsDir).sort()) { + if (f.startsWith('highway-') && f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8')); + } + return parts.join('\n'); +} + test('highway declares the note-state provider slot', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)'); }); test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider'); assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null'); assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot'); @@ -51,7 +66,7 @@ test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteSt }); test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function _makeBundle()'); // The bundle field must point straight at _noteState — not a fresh // arrow each frame (the per-frame allocation the review flagged). @@ -67,7 +82,7 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', }); test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(src, 'function _makeBundle()'); // Same allocation discipline as getNoteState: highway_3d uses this // bundle field to tell "provider attached" from "no provider but @@ -90,7 +105,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2 }); test('_noteState normalizes provider output as documented', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); const fn = extractBlock(fs.readFileSync(primitivesJs, 'utf8'), 'function _noteState(hwState, note, chartTime)'); assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered'); assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw'); @@ -102,9 +117,14 @@ test('_noteState normalizes provider output as documented', () => { }); test('default 2D renderer threads note state into drawNote / drawSustains / chord path', () => { - const src = fs.readFileSync(highwayJs, 'utf8'); + const src = highwaySources(); // drawNote takes the trailing `ns` param. - assert.match(src, /function\s+drawNote\(\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, 'drawNote must accept the trailing ns param'); + // R3c: drawNote moved to ./static/js/highway-draw.js and gained hwState as its FIRST arg + // (createHighway is a factory — a module cannot import per-instance state without two + // panels sharing it). The contract asserted here is unchanged: `ns` is still the TRAILING + // parameter, which is what the note-state threading depends on. + assert.match(src, /function\s+drawNote\(\s*hwState\s*,\s*W\s*,\s*H\s*,\s*x\s*,\s*y\s*,\s*scale\s*,\s*string\s*,\s*fret\s*,\s*opts\s*,\s*ns\s*\)/, + 'drawNote must take hwState first and keep ns as the trailing param'); // drawNotes / drawSustains / drawChords gate the lookup on the provider. // R3c: _noteState gained an explicit hwState first arg (it lives in a module now, and // createHighway is a factory). The CONTRACT here is unchanged and still the point: skip diff --git a/tests/js/highway_teaching_marks.test.js b/tests/js/highway_teaching_marks.test.js index aab4d12..188022d 100644 --- a/tests/js/highway_teaching_marks.test.js +++ b/tests/js/highway_teaching_marks.test.js @@ -32,7 +32,7 @@ const fingerLabel2D = loadFn('static/js/highway-geometry.js', 'teachingFingerLab const degreeLabel2D = loadFn('static/js/highway-geometry.js', 'teachingDegreeLabel'); const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel'); const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel'); -const strumGroupBuckets = loadFn('static/highway.js', 'strumGroupBuckets'); +const strumGroupBuckets = loadFn('static/js/highway-draw.js', 'strumGroupBuckets'); // ── teachingFingerLabel (fg) ─────────────────────────────────────────────────