{"spec_id":"piano-roll-midi","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// piano-roll-midi: MIDI Piano Roll Visualization\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 88/100 | Created: 2026-06-03\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\n\n// Note name helpers\nconst NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];\nconst pitchName = (p) => NOTE_NAMES[p % 12] + (Math.floor(p / 12) - 1);\nconst isBlackKey = (p) => [1, 3, 6, 8, 10].includes(p % 12);\n\n// Imprint sequential colormap: green (#009E73) → blue (#4467A3) for velocity\nconst lerpColor = (hex1, hex2, ratio) => {\n  const parse = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];\n  const [r1, g1, b1] = parse(hex1);\n  const [r2, g2, b2] = parse(hex2);\n  return [Math.round(r1 + (r2 - r1) * ratio), Math.round(g1 + (g2 - g1) * ratio), Math.round(b1 + (b2 - b1) * ratio)];\n};\n\n// MIDI data: 8-measure classical phrase in C major (4/4 time, 32 beats total)\n// Fields: start (beat), duration (beats), pitch (MIDI number), velocity (0–127)\nconst notes = [\n  // Measure 1: C major — rising arpeggio melody over held bass 5th\n  { start: 0,    duration: 4,    pitch: 48, velocity: 72 },  // C3 whole\n  { start: 0,    duration: 4,    pitch: 55, velocity: 68 },  // G3 whole\n  { start: 0,    duration: 0.5,  pitch: 60, velocity: 90 },  // C4\n  { start: 0.5,  duration: 0.5,  pitch: 64, velocity: 82 },  // E4\n  { start: 1,    duration: 0.5,  pitch: 67, velocity: 87 },  // G4\n  { start: 1.5,  duration: 0.5,  pitch: 72, velocity: 93 },  // C5\n  { start: 2,    duration: 0.5,  pitch: 74, velocity: 98 },  // D5\n  { start: 2.5,  duration: 0.5,  pitch: 72, velocity: 88 },  // C5\n  { start: 3,    duration: 0.5,  pitch: 69, velocity: 82 },  // A4\n  { start: 3.5,  duration: 0.5,  pitch: 67, velocity: 77 },  // G4\n\n  // Measure 2: A minor — stepwise melody with lyrical shaping\n  { start: 4,    duration: 4,    pitch: 45, velocity: 70 },  // A2 whole\n  { start: 4,    duration: 4,    pitch: 52, velocity: 65 },  // E3 whole\n  { start: 4,    duration: 0.5,  pitch: 64, velocity: 83 },  // E4\n  { start: 4.5,  duration: 0.5,  pitch: 65, velocity: 78 },  // F4\n  { start: 5,    duration: 0.5,  pitch: 67, velocity: 86 },  // G4\n  { start: 5.5,  duration: 0.5,  pitch: 69, velocity: 91 },  // A4\n  { start: 6,    duration: 1,    pitch: 72, velocity: 94 },  // C5 quarter\n  { start: 7,    duration: 0.5,  pitch: 69, velocity: 80 },  // A4\n  { start: 7.5,  duration: 0.5,  pitch: 67, velocity: 74 },  // G4\n\n  // Measure 3: F major — wider leap to high register\n  { start: 8,    duration: 4,    pitch: 41, velocity: 73 },  // F2 whole\n  { start: 8,    duration: 4,    pitch: 53, velocity: 68 },  // F3 whole\n  { start: 8,    duration: 0.5,  pitch: 65, velocity: 87 },  // F4\n  { start: 8.5,  duration: 0.5,  pitch: 67, velocity: 82 },  // G4\n  { start: 9,    duration: 0.5,  pitch: 69, velocity: 89 },  // A4\n  { start: 9.5,  duration: 0.5,  pitch: 72, velocity: 84 },  // C5\n  { start: 10,   duration: 1,    pitch: 74, velocity: 96 },  // D5 quarter\n  { start: 11,   duration: 0.5,  pitch: 72, velocity: 84 },  // C5\n  { start: 11.5, duration: 0.5,  pitch: 69, velocity: 76 },  // A4\n\n  // Measure 4: G dominant — leading tone tension to first climax\n  { start: 12,   duration: 4,    pitch: 43, velocity: 78 },  // G2 whole\n  { start: 12,   duration: 4,    pitch: 50, velocity: 70 },  // D3 whole\n  { start: 12,   duration: 1,    pitch: 67, velocity: 88 },  // G4 quarter\n  { start: 13,   duration: 0.5,  pitch: 69, velocity: 84 },  // A4\n  { start: 13.5, duration: 0.5,  pitch: 71, velocity: 87 },  // B4\n  { start: 14,   duration: 1,    pitch: 72, velocity: 100 }, // C5 — forte\n  { start: 15,   duration: 1,    pitch: 71, velocity: 79 },  // B4 quarter\n\n  // Measure 5: C major — harmonized dyads (parallel 3rds)\n  { start: 16,   duration: 4,    pitch: 48, velocity: 68 },  // C3 whole\n  { start: 16,   duration: 4,    pitch: 55, velocity: 63 },  // G3 whole\n  { start: 16,   duration: 1,    pitch: 60, velocity: 83 },  // C4 with E4\n  { start: 16,   duration: 1,    pitch: 64, velocity: 80 },  // E4\n  { start: 17,   duration: 1,    pitch: 64, velocity: 78 },  // E4 with G4\n  { start: 17,   duration: 1,    pitch: 67, velocity: 76 },  // G4\n  { start: 18,   duration: 2,    pitch: 67, velocity: 86 },  // G4 half with C5\n  { start: 18,   duration: 2,    pitch: 72, velocity: 84 },  // C5\n\n  // Measure 6: A minor — 16th-note ornament + descending close\n  { start: 20,   duration: 4,    pitch: 45, velocity: 66 },  // A2 whole\n  { start: 20,   duration: 4,    pitch: 52, velocity: 62 },  // E3 whole\n  { start: 20,   duration: 0.25, pitch: 72, velocity: 92 },  // C5 16th\n  { start: 20.25,duration: 0.25, pitch: 74, velocity: 88 },  // D5 16th\n  { start: 20.5, duration: 0.25, pitch: 72, velocity: 85 },  // C5 16th\n  { start: 20.75,duration: 0.25, pitch: 69, velocity: 80 },  // A4 16th\n  { start: 21,   duration: 0.5,  pitch: 67, velocity: 83 },  // G4\n  { start: 21.5, duration: 0.5,  pitch: 65, velocity: 78 },  // F4\n  { start: 22,   duration: 1,    pitch: 64, velocity: 82 },  // E4 quarter\n  { start: 23,   duration: 1,    pitch: 60, velocity: 72 },  // C4 quarter\n\n  // Measure 7: F → G — building intensity toward final climax\n  { start: 24,   duration: 2,    pitch: 41, velocity: 78 },  // F2 half\n  { start: 24,   duration: 2,    pitch: 53, velocity: 73 },  // F3 half\n  { start: 26,   duration: 2,    pitch: 43, velocity: 83 },  // G2 half\n  { start: 26,   duration: 2,    pitch: 50, velocity: 76 },  // D3 half\n  { start: 24,   duration: 0.5,  pitch: 69, velocity: 88 },  // A4\n  { start: 24.5, duration: 0.5,  pitch: 71, velocity: 92 },  // B4\n  { start: 25,   duration: 0.5,  pitch: 72, velocity: 96 },  // C5\n  { start: 25.5, duration: 0.5,  pitch: 74, velocity: 100 }, // D5\n  { start: 26,   duration: 1,    pitch: 76, velocity: 105 }, // E5 — peak\n  { start: 27,   duration: 0.5,  pitch: 74, velocity: 93 },  // D5\n  { start: 27.5, duration: 0.5,  pitch: 72, velocity: 87 },  // C5\n\n  // Measure 8: C major — descending resolution over final chord\n  { start: 28,   duration: 4,    pitch: 48, velocity: 83 },  // C3 whole\n  { start: 28,   duration: 4,    pitch: 55, velocity: 78 },  // G3 whole\n  { start: 28,   duration: 4,    pitch: 60, velocity: 72 },  // C4 whole (chord)\n  { start: 28,   duration: 0.5,  pitch: 72, velocity: 88 },  // C5\n  { start: 28.5, duration: 0.5,  pitch: 71, velocity: 84 },  // B4\n  { start: 29,   duration: 0.5,  pitch: 69, velocity: 80 },  // A4\n  { start: 29.5, duration: 0.5,  pitch: 67, velocity: 75 },  // G4\n  { start: 30,   duration: 2,    pitch: 64, velocity: 82 },  // E4 half\n  { start: 30,   duration: 2,    pitch: 67, velocity: 80 },  // G4 half (chord)\n  { start: 30,   duration: 2,    pitch: 72, velocity: 86 },  // C5 half (chord)\n];\n\n// Pitch display range with margins\nconst MIN_PITCH = Math.min(...notes.map((n) => n.pitch)) - 1;\nconst MAX_PITCH = Math.max(...notes.map((n) => n.pitch)) + 1;\nconst MIN_VEL = Math.min(...notes.map((n) => n.velocity));\nconst MAX_VEL = Math.max(...notes.map((n) => n.velocity));\n\n// Velocity → rgba: hue shifts green→blue, alpha scales with loudness (soft=60%, loud=100%)\nconst velColor = (vel) => {\n  const ratio = (vel - MIN_VEL) / (MAX_VEL - MIN_VEL);\n  const [r, g, b] = lerpColor(t.seq[0], t.seq[1], ratio);\n  const alpha = (0.60 + 0.40 * ratio).toFixed(2);\n  return `rgba(${r},${g},${b},${alpha})`;\n};\n\n// Integer bounds so stepSize:1 generates integer ticks (non-integer min+stepSize = non-integer ticks)\nconst DISPLAY_MIN = MIN_PITCH - 1;\nconst DISPLAY_MAX = MAX_PITCH + 1;\n\n// Mount\nconst canvas = document.createElement('canvas');\ndocument.getElementById('container').appendChild(canvas);\n\n// Custom plugin: piano key backgrounds + beat grid + note rectangles\nconst pianoRollPlugin = {\n  id: 'pianoRoll',\n  beforeDraw(chart) {\n    const { ctx, chartArea: { left, right, top, bottom }, scales: { x, y } } = chart;\n\n    // Black key row shading\n    for (let p = Math.floor(y.min); p <= Math.ceil(y.max); p++) {\n      if (isBlackKey(p)) {\n        const rowTop = y.getPixelForValue(p + 0.5);\n        const rowBottom = y.getPixelForValue(p - 0.5);\n        ctx.fillStyle = THEME === 'light'\n          ? 'rgba(26,26,23,0.07)'\n          : 'rgba(240,239,232,0.07)';\n        ctx.fillRect(left, rowTop, right - left, rowBottom - rowTop);\n      }\n    }\n\n    // Beat gridlines (between measure boundaries)\n    ctx.lineWidth = 0.5;\n    ctx.strokeStyle = THEME === 'light'\n      ? 'rgba(26,26,23,0.10)'\n      : 'rgba(240,239,232,0.10)';\n    for (let beat = 1; beat < 32; beat++) {\n      if (beat % 4 !== 0) {\n        const px = x.getPixelForValue(beat);\n        ctx.beginPath();\n        ctx.moveTo(px, top);\n        ctx.lineTo(px, bottom);\n        ctx.stroke();\n      }\n    }\n\n    // Measure boundary lines (stronger)\n    ctx.lineWidth = 1.5;\n    ctx.strokeStyle = THEME === 'light'\n      ? 'rgba(26,26,23,0.22)'\n      : 'rgba(240,239,232,0.22)';\n    for (let m = 0; m <= 8; m++) {\n      const px = x.getPixelForValue(m * 4);\n      ctx.beginPath();\n      ctx.moveTo(px, top);\n      ctx.lineTo(px, bottom);\n      ctx.stroke();\n    }\n  },\n  afterDraw(chart) {\n    const { ctx, chartArea: { left, right }, scales: { x, y } } = chart;\n    const borderCol = THEME === 'light' ? 'rgba(0,0,0,0.30)' : 'rgba(0,0,0,0.55)';\n\n    notes.forEach((note) => {\n      const x1 = x.getPixelForValue(note.start) + 1;\n      const x2 = x.getPixelForValue(note.start + note.duration) - 1;\n      const y1 = y.getPixelForValue(note.pitch + 0.38);\n      const y2 = y.getPixelForValue(note.pitch - 0.38);\n      const nw = Math.max(x2 - x1, 2);\n      const nh = y2 - y1;\n\n      ctx.fillStyle = velColor(note.velocity);\n      ctx.fillRect(x1, y1, nw, nh);\n      ctx.strokeStyle = borderCol;\n      ctx.lineWidth = 0.5;\n      ctx.strokeRect(x1, y1, nw, nh);\n    });\n\n    // --- Velocity colorbar ---\n    const cbW = right - left;\n    const cbH = 16;\n    const cbTop = chart.height - 52;\n\n    const grad = ctx.createLinearGradient(left, 0, left + cbW, 0);\n    grad.addColorStop(0, t.seq[0]);\n    grad.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = grad;\n    ctx.fillRect(left, cbTop, cbW, cbH);\n    ctx.strokeStyle = THEME === 'light' ? 'rgba(26,26,23,0.20)' : 'rgba(240,239,232,0.20)';\n    ctx.lineWidth = 0.5;\n    ctx.strokeRect(left, cbTop, cbW, cbH);\n\n    // Labels: \"ppp\" left, title center, \"fff\" right\n    const labelY = cbTop + cbH + 5;\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = '12px sans-serif';\n    ctx.textBaseline = 'top';\n    ctx.textAlign = 'left';\n    ctx.fillText('ppp', left, labelY);\n    ctx.textAlign = 'center';\n    ctx.fillText('Velocity (dynamics)', left + cbW / 2, labelY);\n    ctx.textAlign = 'right';\n    ctx.fillText('fff', left + cbW, labelY);\n  },\n};\n\n// Title length scaling (per plot-generator.md)\nconst title = 'piano-roll-midi · javascript · chartjs · anyplot.ai';\nconst titleSize = Math.max(18, Math.round(22 * Math.min(1, 67 / title.length)));\n\nnew Chart(canvas, {\n  type: 'scatter',\n  // Invisible anchor points force Chart.js to initialize the scales and generate ticks\n  data: {\n    datasets: [{\n      data: [{ x: 0, y: DISPLAY_MIN }, { x: 32, y: DISPLAY_MAX }],\n      pointRadius: 0,\n      borderWidth: 0,\n      backgroundColor: 'transparent',\n    }],\n  },\n  plugins: [pianoRollPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 16, right: 32, bottom: 65, left: 0 } },\n    plugins: {\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleSize },\n        padding: { bottom: 14 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: 'linear',\n        min: 0,\n        max: 32,\n        grid: { display: false },\n        border: { color: t.inkSoft },\n        ticks: {\n          stepSize: 4,\n          callback: (val) => (val % 4 === 0 && val < 32) ? `M${val / 4 + 1}` : null,\n          color: t.inkSoft,\n          font: { size: 14 },\n          maxRotation: 0,\n          autoSkip: false,\n        },\n        title: {\n          display: true,\n          text: 'Time (Measures, 4/4)',\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        type: 'linear',\n        min: DISPLAY_MIN,\n        max: DISPLAY_MAX,\n        grid: { display: false },\n        border: { color: t.inkSoft },\n        ticks: {\n          stepSize: 1,\n          callback: (val) => (Number.isInteger(val) && !isBlackKey(val)) ? pitchName(val) : null,\n          color: t.inkSoft,\n          font: { size: 13 },\n          autoSkip: false,\n          maxTicksLimit: 100,\n        },\n        title: {\n          display: true,\n          text: 'Pitch',\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n});\n"}