mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
* Chart-transform plugin capability * PR comments * Cleanup * Fix markdown * CodeRabbit feedback Signed-off-by: Joe <jphinspace@gmail.com> --------- Signed-off-by: Joe <jphinspace@gmail.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
1315 lines
58 KiB
JavaScript
1315 lines
58 KiB
JavaScript
// 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<fontSize, Map<text, width>>
|
|
//
|
|
// 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);
|
|
}
|
|
}
|