{"spec_id":"piano-roll-midi","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\npiano-roll-midi: MIDI Piano Roll Visualization\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the plotly package (same-name script workaround)\nsys.path = [p for p in sys.path if p != os.path.dirname(os.path.abspath(__file__))]\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Velocity colorscale: Imprint blue (soft/piano) → Imprint red (forte/loud)\nVEL_SCALE = [[0.0, \"#4467A3\"], [1.0, \"#AE3030\"]]\n\n# Data - C major chord progression (I-V-vi-IV) with melody over 4 measures\nNOTE_NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\nBLACK_KEY_INDICES = {1, 3, 6, 8, 10}\n\nnotes = [\n    # (start_beat, duration, midi_pitch, velocity)\n    # Measure 1 - C major chord\n    (0.0, 2.0, 48, 80),\n    (0.0, 2.0, 55, 75),\n    (0.0, 2.0, 60, 70),\n    (0.0, 2.0, 64, 72),\n    (0.0, 0.5, 72, 100),\n    (0.5, 0.5, 74, 95),\n    (1.0, 1.0, 76, 110),\n    (2.0, 2.0, 48, 78),\n    (2.0, 2.0, 55, 73),\n    (2.0, 2.0, 60, 68),\n    (2.0, 2.0, 64, 70),\n    (2.0, 0.75, 79, 105),\n    (2.75, 0.25, 76, 85),\n    (3.0, 1.0, 77, 100),\n    # Measure 2 - G major chord\n    (4.0, 2.0, 47, 82),\n    (4.0, 2.0, 55, 76),\n    (4.0, 2.0, 59, 72),\n    (4.0, 2.0, 62, 74),\n    (4.0, 0.5, 76, 108),\n    (4.5, 0.5, 74, 90),\n    (5.0, 1.0, 72, 100),\n    (6.0, 2.0, 47, 80),\n    (6.0, 2.0, 55, 74),\n    (6.0, 2.0, 59, 70),\n    (6.0, 2.0, 62, 72),\n    (6.0, 1.0, 67, 95),\n    (7.0, 0.5, 69, 88),\n    (7.5, 0.5, 71, 92),\n    # Measure 3 - A minor chord\n    (8.0, 2.0, 45, 85),\n    (8.0, 2.0, 52, 78),\n    (8.0, 2.0, 57, 72),\n    (8.0, 2.0, 60, 74),\n    (8.0, 1.0, 72, 112),\n    (9.0, 0.5, 74, 96),\n    (9.5, 0.5, 76, 90),\n    (10.0, 2.0, 45, 83),\n    (10.0, 2.0, 52, 76),\n    (10.0, 2.0, 57, 70),\n    (10.0, 2.0, 64, 72),\n    (10.0, 1.5, 76, 105),\n    (11.5, 0.5, 74, 88),\n    # Measure 4 - F major chord\n    (12.0, 2.0, 53, 88),\n    (12.0, 2.0, 57, 80),\n    (12.0, 2.0, 60, 74),\n    (12.0, 2.0, 65, 76),\n    (12.0, 1.0, 72, 115),\n    (13.0, 0.5, 71, 92),\n    (13.5, 0.5, 69, 85),\n    (14.0, 2.0, 53, 86),\n    (14.0, 2.0, 57, 78),\n    (14.0, 2.0, 60, 72),\n    (14.0, 2.0, 65, 74),\n    (14.0, 2.0, 72, 120),\n]\n\nstarts = np.array([n[0] for n in notes])\ndurations = np.array([n[1] for n in notes])\npitches = np.array([n[2] for n in notes])\nvelocities = np.array([n[3] for n in notes])\n\npitch_min = int(pitches.min()) - 1\npitch_max = int(pitches.max()) + 1\ntotal_beats = 16\nbeats_per_measure = 4\n\n# Classify notes: melody (highest pitch per onset) vs accompaniment\nis_melody = np.zeros(len(notes), dtype=bool)\nfor s in np.unique(starts):\n    mask = starts == s\n    idx = np.where(mask)[0]\n    is_melody[idx[np.argmax(pitches[idx])]] = True\n\n# Velocity → color: Imprint blue (#4467A3 = soft) → Imprint red (#AE3030 = loud)\nvel_min, vel_max = float(velocities.min()), float(velocities.max())\nvel_norm = (velocities - vel_min) / (vel_max - vel_min)\n_r0, _g0, _b0 = 0x44, 0x67, 0xA3\n_r1, _g1, _b1 = 0xAE, 0x30, 0x30\nnote_colors = [\n    f\"rgb({int(_r0 + v * (_r1 - _r0))},{int(_g0 + v * (_g1 - _g0))},{int(_b0 + v * (_b1 - _b0))})\" for v in vel_norm\n]\n\n# Theme-adaptive structural colors\nblack_key_fill = \"rgba(26,26,23,0.07)\" if THEME == \"light\" else \"rgba(240,239,232,0.07)\"\nmeasure_line = \"rgba(26,26,23,0.35)\" if THEME == \"light\" else \"rgba(240,239,232,0.35)\"\nbeat_line = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\npitch_line = \"rgba(26,26,23,0.05)\" if THEME == \"light\" else \"rgba(240,239,232,0.05)\"\n\nMIN_VIS_WIDTH = 0.12\n\n# Plot\nfig = go.Figure()\n\n# Black key row shading\nfor p in range(pitch_min, pitch_max + 1):\n    if (p % 12) in BLACK_KEY_INDICES:\n        fig.add_shape(\n            type=\"rect\",\n            x0=-0.3,\n            x1=total_beats + 0.3,\n            y0=p - 0.5,\n            y1=p + 0.5,\n            fillcolor=black_key_fill,\n            line={\"width\": 0},\n            layer=\"below\",\n        )\n\n# Measure boundary lines\nfor m in range(total_beats // beats_per_measure + 1):\n    fig.add_shape(\n        type=\"line\",\n        x0=m * beats_per_measure,\n        x1=m * beats_per_measure,\n        y0=pitch_min - 0.8,\n        y1=pitch_max + 0.8,\n        line={\"color\": measure_line, \"width\": 2},\n        layer=\"below\",\n    )\n\n# Beat subdivision grid lines\nfor b in range(total_beats + 1):\n    if b % beats_per_measure != 0:\n        fig.add_shape(\n            type=\"line\",\n            x0=b,\n            x1=b,\n            y0=pitch_min - 0.8,\n            y1=pitch_max + 0.8,\n            line={\"color\": beat_line, \"width\": 0.75, \"dash\": \"dot\"},\n            layer=\"below\",\n        )\n\n# Horizontal pitch separator lines\nfor p in range(pitch_min, pitch_max + 2):\n    fig.add_shape(\n        type=\"line\",\n        x0=-0.3,\n        x1=total_beats + 0.3,\n        y0=p - 0.5,\n        y1=p - 0.5,\n        line={\"color\": pitch_line, \"width\": 0.5},\n        layer=\"below\",\n    )\n\n# Measure number labels\nfor m in range(total_beats // beats_per_measure):\n    fig.add_annotation(\n        x=m * beats_per_measure + beats_per_measure / 2,\n        y=pitch_max + 1.5,\n        text=f\"<b>Measure {m + 1}</b>\",\n        showarrow=False,\n        font={\"size\": 10, \"color\": INK_MUTED, \"family\": \"Arial\"},\n        yref=\"y\",\n    )\n\n# Chord labels below the roll\nchord_names = [\"C maj\", \"G maj\", \"A min\", \"F maj\"]\nfor m, chord in enumerate(chord_names):\n    fig.add_annotation(\n        x=m * beats_per_measure + beats_per_measure / 2,\n        y=pitch_min - 1.8,\n        text=f\"<i>{chord}</i>\",\n        showarrow=False,\n        font={\"size\": 10, \"color\": INK_MUTED, \"family\": \"Arial\"},\n        yref=\"y\",\n    )\n\n# Note rectangles — melody notes taller/more opaque for visual hierarchy\nfor i in range(len(notes)):\n    vis_dur = max(durations[i], MIN_VIS_WIDTH)\n    height = 0.42 if is_melody[i] else 0.32\n    opacity = 0.95 if is_melody[i] else 0.72\n    border_w = 2.0 if is_melody[i] else 1.0\n    border_col = \"rgba(255,255,255,0.9)\" if is_melody[i] else \"rgba(255,255,255,0.6)\"\n    fig.add_shape(\n        type=\"rect\",\n        x0=starts[i],\n        x1=starts[i] + vis_dur,\n        y0=pitches[i] - height,\n        y1=pitches[i] + height,\n        fillcolor=note_colors[i],\n        line={\"color\": border_col, \"width\": border_w},\n        layer=\"above\",\n        opacity=opacity,\n    )\n\n# Invisible scatter: hover tooltips + colorbar\nhover_labels = [\n    f\"{'♪ ' if is_melody[i] else ''}{NOTE_NAMES[p % 12]}{p // 12 - 1}\"\n    f\"<br>Beat: {s:.1f}<br>Duration: {d:.2g} beats<br>Velocity: {v}\"\n    f\"<br>{'Melody' if is_melody[i] else 'Accompaniment'}\"\n    for i, (s, d, p, v) in enumerate(notes)\n]\nfig.add_trace(\n    go.Scatter(\n        x=starts + durations / 2,\n        y=pitches,\n        mode=\"markers\",\n        marker={\n            \"size\": 1,\n            \"opacity\": 0,\n            \"color\": velocities,\n            \"colorscale\": VEL_SCALE,\n            \"colorbar\": {\n                \"title\": {\"text\": \"Velocity<br>(MIDI 0–127)\", \"font\": {\"size\": 10, \"color\": INK}},\n                \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n                \"thickness\": 18,\n                \"len\": 0.55,\n                \"outlinewidth\": 0,\n                \"y\": 0.5,\n                \"bgcolor\": ELEVATED_BG,\n                \"bordercolor\": INK_SOFT,\n                \"borderwidth\": 1,\n            },\n            \"cmin\": int(vel_min),\n            \"cmax\": int(vel_max),\n        },\n        text=hover_labels,\n        hoverinfo=\"text\",\n        showlegend=False,\n    )\n)\n\n# Y-axis: every other white key to avoid crowding at half-step pairs\npitch_range = list(range(pitch_min, pitch_max + 1))\nwhite_keys = [p for p in pitch_range if (p % 12) not in BLACK_KEY_INDICES]\ndisplay_pitches = white_keys[::2]\ndisplay_labels = [f\"{NOTE_NAMES[p % 12]}{p // 12 - 1}\" for p in display_pitches]\n\n# X-axis: measure.beat labels\nbeat_ticks = list(range(total_beats + 1))\nbeat_labels = [f\"M{b // beats_per_measure + 1}.{b % beats_per_measure + 1}\" for b in beat_ticks]\n\ntitle_text = \"piano-roll-midi · python · plotly · anyplot.ai\"\ntitle_n = len(title_text)\ntitle_fontsize = round(16 * 67 / title_n) if title_n > 67 else 16\n\n# Style\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": title_text,\n        \"font\": {\"size\": title_fontsize, \"family\": \"Arial, sans-serif\", \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    xaxis={\n        \"title\": {\"text\": \"Beat Position (Measure.Beat)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickvals\": beat_ticks,\n        \"ticktext\": beat_labels,\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"range\": [-0.5, total_beats + 0.5],\n        \"showgrid\": False,\n        \"zeroline\": False,\n        \"linecolor\": INK_SOFT,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Pitch (MIDI Note Name)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickvals\": display_pitches,\n        \"ticktext\": display_labels,\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"range\": [pitch_min - 2.5, pitch_max + 2.2],\n        \"showgrid\": False,\n        \"zeroline\": False,\n        \"linecolor\": INK_SOFT,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"family\": \"Arial, Helvetica, sans-serif\", \"color\": INK},\n    margin={\"l\": 80, \"r\": 110, \"t\": 80, \"b\": 90},\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}