midmid3 / static /visualizer.js
markury's picture
add example and new viz
fa8dc8b
/**
* 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 = `
<div style="display:flex; align-items:center; gap:12px; padding:10px 16px; background:#111; border-bottom:1px solid #222;">
<button id="viz-play" style="background:none; border:none; color:#fff; font-size:22px; cursor:pointer; padding:4px 8px;" title="Play/Pause">&#9654;</button>
<div id="viz-time" style="color:#aaa; font-size:13px; min-width:80px;">0:00 / 0:00</div>
<div style="flex:1; position:relative; height:6px; background:#222; border-radius:3px; cursor:pointer;" id="viz-seekbar">
<div id="viz-seekfill" style="height:100%; background:#7c3aed; border-radius:3px; width:0%; pointer-events:none;"></div>
</div>
<select id="viz-diff" style="background:#1a1a1a; color:#fff; border:1px solid #333; border-radius:4px; padding:2px 6px; font-size:13px;">
<option value="expert">Expert</option>
<option value="hard">Hard</option>
<option value="medium">Medium</option>
<option value="easy">Easy</option>
</select>
</div>
<canvas id="viz-canvas" style="width:100%; display:block;"></canvas>
<div id="viz-sections" style="padding:6px 16px 10px; background:#111; border-top:1px solid #222; color:#666; font-size:11px; min-height:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
`;
canvas = document.getElementById('viz-canvas');
ctx = canvas.getContext('2d');
document.getElementById('viz-play').addEventListener('click', () => {
if (!audio) return;
if (playing) {
audio.pause(); playing = false;
document.getElementById('viz-play').textContent = '\u25B6';
} else {
audio.play(); playing = true;
document.getElementById('viz-play').textContent = '\u23F8';
}
});
document.getElementById('viz-seekbar').addEventListener('click', e => {
if (!audio) return;
const rect = e.currentTarget.getBoundingClientRect();
audio.currentTime = ((e.clientX - rect.left) / rect.width) * getDuration();
});
document.getElementById('viz-diff').addEventListener('change', e => {
currentDiff = e.target.value;
noteCache = buildNoteCache(currentDiff);
});
resize();
window.addEventListener('resize', resize);
}
// ─── Init ────────────────────────────────────────────────────────
async function init() {
const container = document.getElementById('midmid-viz');
if (!container) { console.error('No #midmid-viz element'); return; }
// Space injects window.CHART_DATA; viz-dev fetches from file
if (window.CHART_DATA) {
DATA = window.CHART_DATA;
} else {
const resp = await fetch('/demo-data.json');
if (!resp.ok) {
container.innerHTML = '<div style="padding:40px; text-align:center; color:#f66;">No demo-data.json found. Run: bun run extract</div>';
return;
}
DATA = await resp.json();
}
buildUI(container);
rebuildCaches();
if (DATA.audio_b64) {
audio = new Audio();
audio.src = 'data:audio/' + DATA.audio_format + ';base64,' + DATA.audio_b64;
audio.preload = 'auto';
audio.addEventListener('loadedmetadata', () => { totalDuration = audio.duration; });
audio.addEventListener('ended', () => {
playing = false;
document.getElementById('viz-play').textContent = '\u25B6';
});
}
requestAnimationFrame(draw);
}
init();
// Vite HMR
if (typeof import.meta !== 'undefined' && import.meta.hot) {
import.meta.hot.accept(() => {});
import.meta.hot.dispose(() => { window.removeEventListener('resize', resize); });
}