{"spec_id":"piano-roll-midi","library":"d3","language":"javascript","code":"// anyplot.ai\n// piano-roll-midi: MIDI Piano Roll Visualization\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 92/100 | Created: 2026-06-03\n\nconst t = window.ANYPLOT_TOKENS;\nconst theme = window.ANYPLOT_THEME;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst margin = { top: 80, right: 158, bottom: 75, left: 90 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- MIDI notes: 4-measure C major melody with crescendo/decrescendo arc ---\nconst notes = [\n  // Measure 1 — ascending scale, pp to mf\n  { pitch: 60, start: 0,      duration: 0.5,  velocity: 62 },\n  { pitch: 62, start: 0.5,    duration: 0.5,  velocity: 68 },\n  { pitch: 64, start: 1.0,    duration: 0.5,  velocity: 74 },\n  { pitch: 65, start: 1.5,    duration: 0.5,  velocity: 78 },\n  { pitch: 67, start: 2.0,    duration: 0.5,  velocity: 84 },\n  { pitch: 69, start: 2.5,    duration: 0.5,  velocity: 88 },\n  { pitch: 71, start: 3.0,    duration: 0.5,  velocity: 94 },\n  { pitch: 72, start: 3.5,    duration: 0.5,  velocity: 100 },\n  // Measure 2 — descending, f to mp\n  { pitch: 71, start: 4.0,    duration: 0.5,  velocity: 95 },\n  { pitch: 69, start: 4.5,    duration: 0.5,  velocity: 88 },\n  { pitch: 67, start: 5.0,    duration: 1.0,  velocity: 96 },\n  { pitch: 65, start: 6.0,    duration: 0.5,  velocity: 80 },\n  { pitch: 64, start: 6.5,    duration: 0.5,  velocity: 74 },\n  { pitch: 62, start: 7.0,    duration: 1.0,  velocity: 82 },\n  // Measure 3 — syncopated rhythm, building to climax\n  { pitch: 60, start: 8.0,    duration: 0.25, velocity: 90 },\n  { pitch: 64, start: 8.25,   duration: 0.25, velocity: 88 },\n  { pitch: 67, start: 8.5,    duration: 0.5,  velocity: 100 },\n  { pitch: 64, start: 9.0,    duration: 0.25, velocity: 85 },\n  { pitch: 60, start: 9.25,   duration: 0.25, velocity: 80 },\n  { pitch: 62, start: 9.5,    duration: 0.5,  velocity: 88 },\n  { pitch: 65, start: 10.0,   duration: 0.5,  velocity: 100 },\n  { pitch: 69, start: 10.5,   duration: 0.25, velocity: 108 },\n  { pitch: 67, start: 10.75,  duration: 0.25, velocity: 104 },\n  { pitch: 65, start: 11.0,   duration: 0.5,  velocity: 98 },\n  { pitch: 64, start: 11.5,   duration: 0.25, velocity: 92 },\n  { pitch: 62, start: 11.75,  duration: 0.25, velocity: 86 },\n  // Measure 4 — final resolution: C major chord + fade out\n  { pitch: 60, start: 12.0,   duration: 1.0,  velocity: 112 },\n  { pitch: 64, start: 12.0,   duration: 1.0,  velocity: 104 },\n  { pitch: 67, start: 12.0,   duration: 1.0,  velocity: 96 },\n  { pitch: 69, start: 13.0,   duration: 0.5,  velocity: 88 },\n  { pitch: 67, start: 13.5,   duration: 0.5,  velocity: 82 },\n  { pitch: 64, start: 14.0,   duration: 0.5,  velocity: 76 },\n  { pitch: 62, start: 14.5,   duration: 0.5,  velocity: 70 },\n  { pitch: 60, start: 15.0,   duration: 1.0,  velocity: 64 },\n];\n\nconst totalBeats = 16;\n\n// Note name helpers\nconst NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];\nconst isBlackKey = (midi) => [1, 3, 6, 8, 10].includes(midi % 12);\nconst midiToName = (midi) => `${NOTE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;\n\n// Auto-fit pitch range with 1-semitone padding\nconst minPitch = d3.min(notes, (d) => d.pitch) - 1;  // 59 = B3\nconst maxPitch = d3.max(notes, (d) => d.pitch) + 1;  // 73 = C#5\nconst domainSpan = maxPitch - minPitch + 1;           // 15 semitones\nconst rowH = ih / domainSpan;\n\n// Velocity range\nconst velMin = d3.min(notes, (d) => d.velocity);\nconst velMax = d3.max(notes, (d) => d.velocity);\n\n// Scales\nconst x = d3.scaleLinear().domain([0, totalBeats]).range([0, iw]);\nconst y = d3.scaleLinear()\n  .domain([minPitch - 0.5, maxPitch + 0.5])\n  .range([ih, 0]);\n\n// Velocity color: blue (t.div[2], low) → neutral (t.div[1]) → red (t.div[0], high)\nconst velColor = d3.scaleSequential()\n  .domain([velMin, velMax])\n  .interpolator(d3.interpolateRgbBasis([t.div[2], t.div[1], t.div[0]]));\n\n// Black key row background (slightly darker than page bg)\nconst blackKeyBg = theme === \"light\" ? \"rgba(26,26,23,0.11)\" : \"rgba(240,239,232,0.09)\";\n\nconst pitchRange = d3.range(minPitch, maxPitch + 1);\n\n// --- SVG setup ---\nconst svg = d3.select(\"#container\").append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\nconst defs = svg.append(\"defs\");\ndefs.append(\"clipPath\").attr(\"id\", \"notes-clip\")\n  .append(\"rect\").attr(\"width\", iw).attr(\"height\", ih);\n\n// Velocity gradient for colorbar: bottom=low=blue, top=high=red\nconst grad = defs.append(\"linearGradient\")\n  .attr(\"id\", \"vel-grad\")\n  .attr(\"x1\", \"0%\").attr(\"x2\", \"0%\")\n  .attr(\"y1\", \"100%\").attr(\"y2\", \"0%\");\ngrad.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", t.div[2]);\ngrad.append(\"stop\").attr(\"offset\", \"50%\").attr(\"stop-color\", t.div[1]);\ngrad.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", t.div[0]);\n\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Background: alternating rows for black / white keys ---\ng.selectAll(\".key-row\")\n  .data(pitchRange)\n  .join(\"rect\")\n  .attr(\"class\", \"key-row\")\n  .attr(\"x\", 0)\n  .attr(\"y\", (p) => y(p + 0.5))\n  .attr(\"width\", iw)\n  .attr(\"height\", rowH)\n  .attr(\"fill\", (p) => (isBlackKey(p) ? blackKeyBg : \"transparent\"));\n\n// --- Horizontal row dividers ---\ng.selectAll(\".row-line\")\n  .data(pitchRange)\n  .join(\"line\")\n  .attr(\"class\", \"row-line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", (p) => y(p - 0.5))\n  .attr(\"y2\", (p) => y(p - 0.5))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 0.3);\n\n// --- Vertical gridlines: beat and measure boundaries (skip borders at 0 and totalBeats) ---\nd3.range(1, totalBeats).forEach((beat) => {\n  const isMeasure = beat % 4 === 0;\n  g.append(\"line\")\n    .attr(\"x1\", x(beat)).attr(\"x2\", x(beat))\n    .attr(\"y1\", 0).attr(\"y2\", ih)\n    .attr(\"stroke\", isMeasure ? t.inkSoft : t.grid)\n    .attr(\"stroke-width\", isMeasure ? 1 : 0.4);\n});\n\n// --- Note rectangles ---\nconst noteGap = 2;\nconst notesG = g.append(\"g\").attr(\"clip-path\", \"url(#notes-clip)\");\nnotesG.selectAll(\".note\")\n  .data(notes)\n  .join(\"rect\")\n  .attr(\"class\", \"note\")\n  .attr(\"x\", (d) => x(d.start) + noteGap)\n  .attr(\"y\", (d) => y(d.pitch + 0.5) + noteGap)\n  .attr(\"width\", (d) => Math.max(x(d.start + d.duration) - x(d.start) - noteGap * 2, 6))\n  .attr(\"height\", Math.max(rowH - noteGap * 2, 2))\n  .attr(\"fill\", (d) => velColor(d.velocity))\n  .attr(\"rx\", 3)\n  .attr(\"ry\", 3);\n\n// --- Y axis: note names ---\nconst yAxisG = g.append(\"g\")\n  .call(d3.axisLeft(y)\n    .tickValues(pitchRange)\n    .tickFormat(midiToName)\n    .tickSize(4)\n  );\nyAxisG.selectAll(\"text\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-family\", \"monospace\");\nyAxisG.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// Emphasize C notes (octave markers)\nyAxisG.selectAll(\"text\")\n  .filter((d) => d % 12 === 0)\n  .attr(\"fill\", t.ink)\n  .style(\"font-weight\", \"700\")\n  .style(\"font-size\", \"15px\");\n\n// Y axis label\nsvg.append(\"text\")\n  .attr(\"transform\", `translate(${28},${margin.top + ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Pitch\");\n\n// --- X axis: beats and measure labels ---\nconst xAxisG = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`);\n\nxAxisG.append(\"line\")\n  .attr(\"x1\", 0).attr(\"x2\", iw)\n  .attr(\"y1\", 0).attr(\"y2\", 0)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\n// Measure labels (centered within each measure)\nxAxisG.selectAll(\".measure-label\")\n  .data(d3.range(4))\n  .join(\"text\")\n  .attr(\"class\", \"measure-label\")\n  .attr(\"x\", (m) => x(m * 4 + 2))\n  .attr(\"y\", 32)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text((m) => `Measure ${m + 1}`);\n\n// Beat tick marks and sub-beat labels\nd3.range(0, totalBeats + 1).forEach((beat) => {\n  xAxisG.append(\"line\")\n    .attr(\"x1\", x(beat)).attr(\"x2\", x(beat))\n    .attr(\"y1\", 0)\n    .attr(\"y2\", beat % 4 === 0 ? 10 : 5)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", beat % 4 === 0 ? 1.5 : 0.8);\n});\n\n// Beat numbers within measures (2, 3, 4)\nd3.range(0, totalBeats).forEach((beat) => {\n  const beatInMeasure = beat % 4;\n  if (beatInMeasure !== 0) {\n    xAxisG.append(\"text\")\n      .attr(\"x\", x(beat))\n      .attr(\"y\", 19)\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"fill\", t.inkSoft)\n      .style(\"font-size\", \"12px\")\n      .style(\"opacity\", \"0.75\")\n      .text(beatInMeasure + 1);\n  }\n});\n\n// X axis label\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 62)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Time (Beats)\");\n\n// --- Velocity colorbar ---\nconst cbW = 18;\nconst cbH = Math.round(ih * 0.65);\nconst cbX = margin.left + iw + 32;\nconst cbY = margin.top + Math.round((ih - cbH) / 2);\n\nsvg.append(\"rect\")\n  .attr(\"x\", cbX).attr(\"y\", cbY)\n  .attr(\"width\", cbW).attr(\"height\", cbH)\n  .attr(\"fill\", \"url(#vel-grad)\")\n  .attr(\"rx\", 3);\n\nsvg.append(\"rect\")\n  .attr(\"x\", cbX).attr(\"y\", cbY)\n  .attr(\"width\", cbW).attr(\"height\", cbH)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 0.5)\n  .attr(\"rx\", 3);\n\n// Colorbar axis\nconst velScale = d3.scaleLinear()\n  .domain([velMin, velMax])\n  .range([cbY + cbH, cbY]);\n\nconst cbAxisG = svg.append(\"g\")\n  .attr(\"transform\", `translate(${cbX + cbW}, 0)`)\n  .call(d3.axisRight(velScale).ticks(5).tickSize(4));\ncbAxisG.selectAll(\"text\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\");\ncbAxisG.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\ncbAxisG.select(\".domain\").attr(\"stroke\", \"none\");\n\nsvg.append(\"text\")\n  .attr(\"x\", cbX + cbW / 2)\n  .attr(\"y\", cbY - 14)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"Velocity\");\n\n// Dynamic range markers (pp, mf, ff) to the left of the colorbar\n[{ f: 0.1, label: \"pp\" }, { f: 0.5, label: \"mf\" }, { f: 0.9, label: \"ff\" }].forEach(({ f, label }) => {\n  const vel = velMin + f * (velMax - velMin);\n  svg.append(\"text\")\n    .attr(\"x\", cbX - 6)\n    .attr(\"y\", velScale(vel) + 4)\n    .attr(\"text-anchor\", \"end\")\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-style\", \"italic\")\n    .text(label);\n});\n\n// --- Title ---\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"piano-roll-midi · javascript · d3 · anyplot.ai\");\n"}