/** * midmid chart visualizer – Guitar Hero-style vertical track * * Standalone module — same rendering logic used in the Gradio Space. * Edit this file, Vite HMR picks it up instantly. * * To export back to the Space: the buildVisualizerHTML() in visualizer.py * inlines this into an iframe. Keep the DOM structure and DATA contract * the same so it stays compatible. * * DATA contract (chart JSON): * resolution: number (ticks per quarter, usually 192) * bpm: number * tempo_events: [{tick, bpm}, ...] * time_signatures: [{tick, num, den}, ...] * sections: [{tick, label}, ...] * beats: [{tick, downbeat}, ...] * notes: { expert: [{tick, frets: number[], sustain, hopo}, ...], hard: [...], ... } * audio_b64: string (base64-encoded OGG) * audio_format: string */ // ─── Colors & constants ────────────────────────────────────────── const FRET_COLORS = ['#22c55e', '#ef4444', '#eab308', '#3b82f6', '#f97316']; const FRET_GLOW = ['#4ade80', '#f87171', '#facc15', '#60a5fa', '#fb923c']; const LANE_COUNT = 5; // ─── Track geometry tunables ───────────────────────────────────── const VISIBLE_SEC = 4.5; // seconds of future notes visible on track const CANVAS_ASPECT = 0.72; // height = width * aspect (responsive) const CANVAS_MIN_H = 400; const CANVAS_MAX_H = 700; const STRIKE_Y_FRAC = 0.88; // strikeline Y (fraction from top) const TOP_Y_FRAC = 0.04; // top of visible track const BOTTOM_W_FRAC = 0.52; // track width at strikeline const TOP_W_FRAC = 0.14; // track width at far end const NOTE_LANE_RATIO = 0.42; // note rx as fraction of lane width (~84% diameter) const NOTE_SQUISH = 0.40; // constant ellipse squish (width:height ratio) // ─── State ─────────────────────────────────────────────────────── let DATA = null, audio = null, canvas, ctx; let W, H; let currentDiff = 'expert'; let playing = false; let noteCache = [], beatCache = [], sectionCache = []; let tempoMap = [], RES = 192, totalDuration = 0; // Computed on resize let strikeY, topY, centerX, bottomW, topW, zFar, sFar, noteRX; // ─── Timing ────────────────────────────────────────────────────── function tickToSec(tick) { let sec = 0, prevTick = 0, bpm = tempoMap[0].bpm; for (let i = 1; i < tempoMap.length; i++) { if (tempoMap[i].tick > tick) break; sec += (tempoMap[i].tick - prevTick) / RES * 60 / bpm; prevTick = tempoMap[i].tick; bpm = tempoMap[i].bpm; } return sec + (tick - prevTick) / RES * 60 / bpm; } // ─── Caching ───────────────────────────────────────────────────── function buildNoteCache(diff) { return (DATA.notes[diff] || []).map(n => ({ sec: tickToSec(n.tick), frets: n.frets, sustainSec: n.sustain > 0 ? tickToSec(n.tick + n.sustain) - tickToSec(n.tick) : 0, hopo: n.hopo, })); } function rebuildCaches() { tempoMap = DATA.tempo_events.map(e => ({ tick: e.tick, bpm: e.bpm })); RES = DATA.resolution; noteCache = buildNoteCache(currentDiff); // 3-level beat subdivisions (Moonscraper style): // level 0 = measure line, 1 = beat line, 2 = sub-beat (eighth note) const rawBeats = DATA.beats.map(b => ({ sec: tickToSec(b.tick), downbeat: b.downbeat })); beatCache = []; for (let i = 0; i < rawBeats.length; i++) { beatCache.push({ sec: rawBeats[i].sec, level: rawBeats[i].downbeat ? 0 : 1 }); if (i < rawBeats.length - 1) { beatCache.push({ sec: (rawBeats[i].sec + rawBeats[i + 1].sec) / 2, level: 2 }); } } sectionCache = DATA.sections.map(s => ({ sec: tickToSec(s.tick), label: s.label })); } // ─── Duration & format ─────────────────────────────────────────── function getDuration() { if (totalDuration && isFinite(totalDuration)) return totalDuration; const all = Object.values(DATA.notes).flat().map(n => tickToSec(n.tick + (n.sustain || 0))); return all.length ? Math.max(...all) + 5 : 120; } function fmt(s) { const m = Math.floor(s / 60), sc = Math.floor(s % 60); return m + ':' + (sc < 10 ? '0' : '') + sc; } // ─── Resize ────────────────────────────────────────────────────── function resize() { const container = document.getElementById('midmid-viz'); W = container.clientWidth; H = Math.round(Math.max(CANVAS_MIN_H, Math.min(CANVAS_MAX_H, W * CANVAS_ASPECT))); canvas.width = W * devicePixelRatio; canvas.height = H * devicePixelRatio; canvas.style.height = H + 'px'; ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); centerX = W / 2; strikeY = H * STRIKE_Y_FRAC; topY = H * TOP_Y_FRAC; bottomW = W * BOTTOM_W_FRAC; topW = W * TOP_W_FRAC; // Perspective derived from the taper: the width ratio IS the depth ratio zFar = BOTTOM_W_FRAC / TOP_W_FRAC; // ≈ 3.71 sFar = 1 / zFar; // ≈ 0.269 // Note size proportional to lane width (Moonscraper: ~1:1 sprite in 1-unit lane) noteRX = (bottomW / LANE_COUNT) * NOTE_LANE_RATIO; } // ─── True perspective projection ───────────────────────────────── // One 1/z calculation drives everything: Y position, track width, // note size, and lane spacing — so beat-line gaps look correct. const clamp01 = v => Math.max(0, Math.min(1, v)); /** Project a time-offset into screen space. * Returns { y, w, s } where s is the perspective scale factor (1 at * strikeline, sFar at the far end). Width, note size, etc. all scale by s. */ function project(secAhead) { const t = clamp01(secAhead / VISIBLE_SEC); // 0 → 1 in world space const z = 1 + t * (zFar - 1); // linear depth const s = 1 / z; // perspective scale const y = strikeY - (1 - s) / (1 - sFar) * (strikeY - topY); const w = bottomW * s; return { y, w, s }; } /** Screen position for a lane at a given time offset from strikeline */ function getPoint(lane, secAhead) { const { y, w, s } = project(Math.max(0, secAhead)); const lw = w / LANE_COUNT; const x = centerX - w / 2 + (lane + 0.5) * lw; return { x, y, scale: s }; } // ─── Color helpers ─────────────────────────────────────────────── function hexRgb(hex) { return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; } function rgba(r, g, b, a) { return `rgba(${r},${g},${b},${a})`; } function colorAlpha(hex, a) { const [r, g, b] = hexRgb(hex); return rgba(r, g, b, a); } // ─── Drawing: track surface ────────────────────────────────────── function drawTrackSurface() { const bL = centerX - bottomW / 2, bR = centerX + bottomW / 2; const tL = centerX - topW / 2, tR = centerX + topW / 2; const grad = ctx.createLinearGradient(0, topY, 0, strikeY); grad.addColorStop(0, '#0d0d0d'); grad.addColorStop(0.6, '#141414'); grad.addColorStop(1, '#1a1a1a'); ctx.beginPath(); ctx.moveTo(bL, strikeY); ctx.lineTo(tL, topY); ctx.lineTo(tR, topY); ctx.lineTo(bR, strikeY); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // Edge rails ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(bL, strikeY); ctx.lineTo(tL, topY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(bR, strikeY); ctx.lineTo(tR, topY); ctx.stroke(); } function drawLaneLines() { for (let i = 1; i < LANE_COUNT; i++) { const frac = i / LANE_COUNT; const bx = centerX - bottomW / 2 + frac * bottomW; const tx = centerX - topW / 2 + frac * topW; ctx.beginPath(); ctx.moveTo(bx, strikeY); ctx.lineTo(tx, topY); ctx.strokeStyle = '#1f1f1f'; ctx.lineWidth = 1; ctx.stroke(); } } function drawBeatLines(t) { // 3-level line styles matching Moonscraper: // 0 = measure (bold), 1 = beat (medium), 2 = sub-beat (faint) const styles = [ { color: 'rgba(255,255,255,0.25)', width: 2 }, { color: 'rgba(255,255,255,0.10)', width: 1 }, { color: 'rgba(255,255,255,0.04)', width: 0.5 }, ]; for (const beat of beatCache) { const ahead = beat.sec - t; if (ahead < 0 || ahead > VISIBLE_SEC) continue; const { y, w } = project(ahead); const st = styles[beat.level]; ctx.beginPath(); ctx.moveTo(centerX - w / 2, y); ctx.lineTo(centerX + w / 2, y); ctx.strokeStyle = st.color; ctx.lineWidth = st.width; ctx.stroke(); } } function drawSectionMarkers(t) { for (const sec of sectionCache) { const ahead = sec.sec - t; if (ahead < 0 || ahead > VISIBLE_SEC) continue; const { y, w, s } = project(ahead); const right = centerX + w / 2; if (s > 0.25) { ctx.fillStyle = colorAlpha('#7c3aed', 0.4 + 0.5 * s); ctx.font = `${Math.max(9, Math.round(11 * s))}px system-ui`; ctx.textAlign = 'left'; ctx.fillText(sec.label, right + 8, y + 4); } } } // ─── Drawing: strikeline & fret buttons ────────────────────────── function drawStrikeline() { const left = centerX - bottomW / 2, right = centerX + bottomW / 2; // Glow band const grad = ctx.createLinearGradient(0, strikeY - 14, 0, strikeY + 14); grad.addColorStop(0, 'rgba(255,255,255,0)'); grad.addColorStop(0.5, 'rgba(255,255,255,0.08)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = grad; ctx.fillRect(left, strikeY - 14, right - left, 28); // Line ctx.beginPath(); ctx.moveTo(left, strikeY); ctx.lineTo(right, strikeY); ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 2; ctx.stroke(); } function drawFretButtons(fretRise) { for (let i = 0; i < LANE_COUNT; i++) { const pt = getPoint(i, 0); const cx = pt.x, cy = strikeY; const rx = noteRX * 1.1; const ry = rx * NOTE_SQUISH; const color = FRET_COLORS[i]; const rise = fretRise[i]; // 0 = idle, 1 = fully raised const active = rise > 0.05; // Soft pulse glow (scales with rise) if (active) { const gr = rx * 1.6; const pulse = ctx.createRadialGradient(cx, cy, rx * 0.5, cx, cy, gr); pulse.addColorStop(0, colorAlpha(color, 0.25 * rise)); pulse.addColorStop(1, colorAlpha(color, 0)); ctx.beginPath(); ctx.ellipse(cx, cy, gr, gr * NOTE_SQUISH, 0, 0, Math.PI * 2); ctx.fillStyle = pulse; ctx.fill(); } // Base body ctx.beginPath(); ctx.ellipse(cx, cy, rx * 1.08, ry * 1.08, 0, 0, Math.PI * 2); ctx.fillStyle = '#1a1a1a'; ctx.fill(); // Coloured outer ring (brightens with rise) ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.strokeStyle = active ? color : colorAlpha(color, 0.7); ctx.lineWidth = 3 + rise; ctx.stroke(); // Smooth rise offset (shorter travel = reaches full height quicker visually) const riseH = ry * 0.38 * rise; const rY = cy - riseH; // Dark cylinder wall (visible proportional to rise) if (active) { ctx.beginPath(); ctx.ellipse(cx, cy, rx * 0.9, ry * 0.9, 0, 0, Math.PI * 2); ctx.fillStyle = '#222'; ctx.fill(); } // Silver ring (rises smoothly) — radial gradient for metallic look ctx.beginPath(); ctx.ellipse(cx, rY, rx * 0.9, ry * 0.9, 0, 0, Math.PI * 2); const silver = ctx.createRadialGradient( cx - rx * 0.2, rY - ry * 0.15, rx * 0.05, cx, rY, rx * 0.9 ); silver.addColorStop(0, '#d8d8d8'); silver.addColorStop(0.3, '#b0b0b0'); silver.addColorStop(0.7, '#808080'); silver.addColorStop(1, '#606060'); ctx.fillStyle = silver; ctx.fill(); // Dark gap inside silver ring ctx.beginPath(); ctx.ellipse(cx, rY, rx * 0.75, ry * 0.75, 0, 0, Math.PI * 2); ctx.fillStyle = '#111'; ctx.fill(); // Center: dark when idle, glowing lane colour when raised (fades with rise) const [cr, cg, cb] = hexRgb(color); if (rise > 0.05) { const glow = ctx.createRadialGradient(cx, rY, 0, cx, rY, rx * 0.62); glow.addColorStop(0, rgba( Math.round(10 + (Math.min(255, cr + 80) - 10) * rise), Math.round(10 + (Math.min(255, cg + 80) - 10) * rise), Math.round(10 + (Math.min(255, cb + 80) - 10) * rise), 1)); glow.addColorStop(0.7, rgba( Math.round(10 + cr * rise), Math.round(10 + cg * rise), Math.round(10 + cb * rise), 1)); glow.addColorStop(1, rgba( Math.round(10 + Math.max(0, cr - 20) * rise), Math.round(10 + Math.max(0, cg - 20) * rise), Math.round(10 + Math.max(0, cb - 20) * rise), 1)); ctx.beginPath(); ctx.ellipse(cx, rY, rx * 0.65, ry * 0.65, 0, 0, Math.PI * 2); ctx.fillStyle = glow; ctx.fill(); } else { ctx.beginPath(); ctx.ellipse(cx, rY, rx * 0.65, ry * 0.65, 0, 0, Math.PI * 2); ctx.fillStyle = '#0a0a0a'; ctx.fill(); } } } // ─── Drawing: note puck (3D layered) ───────────────────────────── // Matches the real GH note structure visible in reference screenshots: // black base → dark coloured side → dark ring gap → bright top face → solid white cap function drawNotePuck(cx, cy, scale, color, isHopo) { const rx = noteRX * scale; const ry = rx * NOTE_SQUISH; if (rx < 3) return; const [cr, cg, cb] = hexRgb(color); const pH = ry * 0.6; // visible side-band height // 1 ── Shadow on track ctx.beginPath(); ctx.ellipse(cx, cy + pH + ry * 0.12, rx * 1.04, ry * 0.45, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fill(); // 2 ── Black base rim ctx.beginPath(); ctx.ellipse(cx, cy + pH, rx * 1.01, ry, 0, 0, Math.PI * 2); ctx.fillStyle = '#080808'; ctx.fill(); // 3 ── Side band (white/silver rim — the visible puck edge below the colour) ctx.beginPath(); ctx.ellipse(cx, cy + pH * 0.45, rx, ry, 0, 0, Math.PI * 2); ctx.fillStyle = '#c8c8c8'; ctx.fill(); // 4 ── Dark separation ring — drawn full-size, then top face covers most // of it, leaving a visible dark border (the groove between top & side) ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fillStyle = '#0e0e0e'; ctx.fill(); // 5 ── Top face — inset slightly so the dark ring shows as a hard border ctx.beginPath(); ctx.ellipse(cx, cy - ry * 0.04, rx * 0.92, ry * 0.85, 0, 0, Math.PI * 2); const topGrad = ctx.createLinearGradient(cx - rx, cy - ry, cx + rx * 0.3, cy + ry * 0.5); topGrad.addColorStop(0, rgba(Math.min(255, cr + 40), Math.min(255, cg + 40), Math.min(255, cb + 40), 1)); topGrad.addColorStop(0.5, rgba(cr, cg, cb, 1)); topGrad.addColorStop(1, rgba(Math.max(0, cr - 15), Math.max(0, cg - 15), Math.max(0, cb - 15), 1)); ctx.fillStyle = topGrad; ctx.fill(); // 6 ── White cap — shifted toward top of face (perspective: looking down at puck) // Dark ring around cap eats into the "forehead", leaving big coloured "chin" const capY = cy - ry * 0.32; if (!isHopo) { // Dark ring around cap — large enough to merge with outer dark ring at // the top, so zero colour is visible above the cap (no "forehead") ctx.beginPath(); ctx.ellipse(cx, capY, rx * 0.55, ry * 0.58, 0, 0, Math.PI * 2); ctx.fillStyle = '#0e0e0e'; ctx.fill(); // Cap base (grey edge — gives the cap its own visible thickness) ctx.beginPath(); ctx.ellipse(cx, capY, rx * 0.46, ry * 0.42, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(175,175,175,0.95)'; ctx.fill(); // Cap top (bright white) ctx.beginPath(); ctx.ellipse(cx, capY - ry * 0.06, rx * 0.39, ry * 0.33, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(238,238,238,0.97)'; ctx.fill(); // Cap highlight ctx.beginPath(); ctx.ellipse(cx, capY - ry * 0.12, rx * 0.24, ry * 0.19, 0, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); } else { // HOPO: dark open center, same position ctx.beginPath(); ctx.ellipse(cx, capY, rx * 0.55, ry * 0.58, 0, 0, Math.PI * 2); ctx.fillStyle = '#0e0e0e'; ctx.fill(); ctx.beginPath(); ctx.ellipse(cx, capY, rx * 0.32, ry * 0.28, 0, 0, Math.PI * 2); ctx.fillStyle = '#080808'; ctx.fill(); } } // ─── Drawing: sustain tails ────────────────────────────────────── function drawSustainTail(fret, startSec, endSec, t, color, isPlaying) { const clipStart = Math.max(startSec - t, 0); const clipEnd = Math.min(endSec - t, VISIBLE_SEC); if (clipStart >= clipEnd) return; const steps = Math.max(8, Math.ceil((clipEnd - clipStart) * 8)); const [cr, cg, cb] = hexRgb(color); // When actively playing, the "consumed" portion glows like a lightsaber const playing = isPlaying && startSec <= t; // Cache sampled points along the sustain const pts = []; for (let i = 0; i <= steps; i++) { pts.push(getPoint(fret, clipStart + (clipEnd - clipStart) * (i / steps))); } // Outer glow — use shadowBlur for soft falloff instead of a wide hard shape ctx.save(); if (playing) { ctx.shadowColor = rgba(cr, cg, cb, 0.8); ctx.shadowBlur = noteRX * 0.5; } // Main sustain strip ctx.beginPath(); for (let i = 0; i <= steps; i++) { const hw = Math.max(1, noteRX * pts[i].scale * (playing ? 0.16 : 0.12)); if (i === 0) ctx.moveTo(pts[i].x - hw, pts[i].y); else ctx.lineTo(pts[i].x - hw, pts[i].y); } for (let i = steps; i >= 0; i--) { ctx.lineTo(pts[i].x + Math.max(1, noteRX * pts[i].scale * (playing ? 0.16 : 0.12)), pts[i].y); } ctx.closePath(); ctx.fillStyle = playing ? rgba(Math.min(255,cr+60), Math.min(255,cg+60), Math.min(255,cb+60), 0.9) : rgba(cr, cg, cb, 0.45); ctx.fill(); ctx.shadowBlur = 0; ctx.restore(); // White-hot center when playing (lightsaber core) if (playing) { ctx.beginPath(); for (let i = 0; i <= steps; i++) { const hw = Math.max(0.3, noteRX * pts[i].scale * 0.05); if (i === 0) ctx.moveTo(pts[i].x - hw, pts[i].y); else ctx.lineTo(pts[i].x - hw, pts[i].y); } for (let i = steps; i >= 0; i--) { ctx.lineTo(pts[i].x + Math.max(0.3, noteRX * pts[i].scale * 0.05), pts[i].y); } ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fill(); } } // ─── Fade overlay at vanishing end ─────────────────────────────── function drawFadeOverlay() { const h = (strikeY - topY) * 0.18; const grad = ctx.createLinearGradient(0, topY - 5, 0, topY + h); grad.addColorStop(0, '#0a0a0a'); grad.addColorStop(1, 'rgba(10,10,10,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, topY + h); } // ─── Main draw loop ────────────────────────────────────────────── function draw() { const t = audio ? audio.currentTime || 0 : 0; const dur = getDuration(); // Update UI controls const seekFill = document.getElementById('viz-seekfill'); const timeDiv = document.getElementById('viz-time'); const secDiv = document.getElementById('viz-sections'); if (seekFill) seekFill.style.width = (t / dur * 100) + '%'; if (timeDiv) timeDiv.textContent = fmt(t) + ' / ' + fmt(dur); let curSec = ''; for (let i = sectionCache.length - 1; i >= 0; i--) { if (sectionCache[i].sec <= t) { curSec = sectionCache[i].label; break; } } if (secDiv) secDiv.textContent = curSec; // ── Clear ── ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H); // ── Track structure ── drawTrackSurface(); drawLaneLines(); drawBeatLines(t); drawSectionMarkers(t); // ── Collect visible notes (future only — past notes vanish) ── const viewEnd = t + VISIBLE_SEC; const visible = []; for (const note of noteCache) { if (note.sec > viewEnd) break; if (note.sec + Math.max(note.sustainSec, 0) < t) continue; visible.push(note); } // ── Sustain tails (back-to-front: furthest first) ── for (let i = visible.length - 1; i >= 0; i--) { const note = visible[i]; if (note.sustainSec <= 0) continue; for (const fret of note.frets) { if (fret > 4) continue; const playing = note.sec <= t && note.sec + note.sustainSec > t; drawSustainTail(fret, note.sec, note.sec + note.sustainSec, t, FRET_COLORS[fret], playing); } } // ── Per-fret rise animation (0 = idle, 1 = fully raised) ── // Also track which frets are actively sustaining (for glow) const fretRise = [0, 0, 0, 0, 0]; const fretSustaining = [false, false, false, false, false]; const RISE_BEFORE = 0.10; // start early so cylinder is up before note touches fret const RISE_HOLD = 0.04; // hold at full rise after note passes before falling const RISE_AFTER = 0.08; // fall back with gravity ease for (const note of noteCache) { const ahead = note.sec - t; const noteEnd = note.sec + note.sustainSec; const endAhead = noteEnd - t; if (ahead > RISE_BEFORE + 0.5) break; if (endAhead < -RISE_HOLD - RISE_AFTER - 0.5 && ahead < -RISE_HOLD - RISE_AFTER - 0.5) continue; let rise = 0; if (note.sustainSec > 0 && ahead <= 0 && endAhead > 0) { // Sustain actively playing — fully raised rise = 1; for (const fret of note.frets) { if (fret <= 4) fretSustaining[fret] = true; } } else if (ahead > 0 && ahead < RISE_BEFORE) { // Approaching — rise up rise = 1 - ahead / RISE_BEFORE; } else { // Falling back — use the END of the note (or sustain) as reference const fallRef = note.sustainSec > 0 ? endAhead : ahead; if (fallRef <= 0 && fallRef > -RISE_HOLD) { // Hold at peak briefly rise = 1; } else if (fallRef <= -RISE_HOLD && fallRef > -RISE_HOLD - RISE_AFTER) { // Then fall with gravity const f = 1 + (fallRef + RISE_HOLD) / RISE_AFTER; rise = f * f; } } for (const fret of note.frets) { if (fret <= 4) fretRise[fret] = Math.max(fretRise[fret], rise); } } // ── Notes (back-to-front: furthest first) ── for (let i = visible.length - 1; i >= 0; i--) { const note = visible[i]; const ahead = note.sec - t; if (ahead < 0) continue; // already played — vanish for (const fret of note.frets) { if (fret > 4) continue; const pt = getPoint(fret, ahead); // Glow when approaching strikeline if (ahead < 0.2) { const intensity = 1 - ahead / 0.2; const gr = noteRX * pt.scale * 2.5; const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, gr); glow.addColorStop(0, colorAlpha(FRET_GLOW[fret], 0.35 * intensity)); glow.addColorStop(1, colorAlpha(FRET_GLOW[fret], 0)); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(pt.x, pt.y, gr, 0, Math.PI * 2); ctx.fill(); } drawNotePuck(pt.x, pt.y, pt.scale, FRET_COLORS[fret], note.hopo); } } // ── Overlays ── drawFadeOverlay(); drawStrikeline(); drawFretButtons(fretRise); // ── HUD text ── ctx.fillStyle = '#555'; ctx.font = '11px system-ui'; ctx.textAlign = 'right'; ctx.fillText(`${noteCache.length} notes (${currentDiff})`, W - 16, 20); ctx.textAlign = 'left'; requestAnimationFrame(draw); } // ─── UI scaffolding ────────────────────────────────────────────── function buildUI(container) { container.style.background = '#0a0a0a'; container.style.borderRadius = '12px'; container.style.overflow = 'hidden'; container.innerHTML = `