// 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. An active chart transform substitutes its staged view. const src = hwState._xfNotes !== null ? hwState._xfNotes : 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. // An active chart transform (_xfNotes) substitutes its staged view. const src = hwState._xfNotes !== null ? hwState._xfNotes : 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._xfChords !== null ? hwState._xfChords : 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, _effChordTemplates(hwState)); 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 }; } // Effective chord templates: an active chart transform substitutes its // re-indexed table (identity change also invalidates the render cache). export function _effChordTemplates(hwState) { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; } // 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 effTemplates = _effChordTemplates(hwState); const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates; if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return; hwState._chordRenderCacheSrc = src; hwState._chordRenderCacheInverted = hwState._inverted; hwState._chordRenderCacheTemplates = effTemplates; // 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, effTemplates); 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, _effChordTemplates(hwState)); 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, _effChordTemplates(hwState)); 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); } }