{"spec_id":"piano-roll-midi","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\npiano-roll-midi: MIDI Piano Roll Visualization\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport shutil\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot.export import ggsave as export_ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme tokens (Imprint palette — theme-adaptive chrome)\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# Black key row background — slightly offset from PAGE_BG for subtle contrast\nBLACK_KEY_BG = \"#E4E3DC\" if THEME == \"light\" else \"#252522\"\nBEAT_LINE = \"rgba(26,26,23,0.18)\" if THEME == \"light\" else \"rgba(240,239,232,0.18)\"\nMEASURE_LINE = \"rgba(26,26,23,0.50)\" if THEME == \"light\" else \"rgba(240,239,232,0.50)\"\n\n# Data — Cmaj–Am–F–G chord progression with melody, 8 measures\nnp.random.seed(42)\n\nblack_semitones = {1, 3, 6, 8, 10}\nblack_key_pitches = {p for p in range(0, 128) if (p % 12) in black_semitones}\n\nchords = [\n    # Measure 1: C major — building (half notes)\n    (0, 2, [48, 52, 55], [55, 50, 45]),\n    (2, 2, [48, 52, 55], [60, 55, 50]),\n    # Measure 2: A minor — softer\n    (4, 4, [45, 52, 57, 60], [42, 40, 38, 48]),\n    # Measure 3: F major — growing (half notes)\n    (8, 2, [53, 57, 60], [65, 60, 55]),\n    (10, 2, [53, 57, 60], [72, 68, 62]),\n    # Measure 4: G major — strong\n    (12, 4, [47, 50, 55, 59], [82, 78, 72, 88]),\n    # Measure 5: C major — restart softer (half notes)\n    (16, 2, [48, 52, 55], [48, 42, 38]),\n    (18, 2, [48, 52, 55], [52, 48, 42]),\n    # Measure 6: A minor — quiet\n    (20, 4, [45, 52, 57, 60], [40, 38, 35, 45]),\n    # Measure 7: F major — building to climax (half notes)\n    (24, 2, [53, 57, 60], [75, 70, 65]),\n    (26, 2, [53, 57, 60], [85, 80, 75]),\n    # Measure 8: G major — fortissimo resolve\n    (28, 4, [47, 50, 55, 59], [98, 92, 88, 105]),\n]\n\nmelody_notes = [\n    (0, 1, 72, 85),\n    (1, 0.5, 74, 75),\n    (1.5, 0.5, 76, 70),\n    (2, 1, 79, 100),\n    (3, 0.5, 76, 80),\n    (3.5, 0.5, 72, 70),\n    (4, 1, 69, 90),\n    (5, 0.5, 67, 70),\n    (5.5, 0.5, 65, 65),\n    (6, 1, 64, 75),\n    (7, 0.5, 62, 55),\n    (7.5, 0.5, 64, 60),\n    (8, 0.5, 62, 70),\n    (8.5, 0.5, 64, 75),\n    (9, 0.5, 65, 80),\n    (9.5, 0.5, 67, 85),\n    (10, 1, 72, 95),\n    (11, 1, 77, 105),\n    (12, 1, 76, 90),\n    (13, 0.5, 74, 75),\n    (13.5, 0.5, 72, 68),\n    (14, 2, 76, 100),\n    (16, 0.5, 79, 95),\n    (16.5, 0.5, 76, 70),\n    (17, 1, 72, 80),\n    (18, 0.5, 69, 60),\n    (18.5, 0.5, 67, 55),\n    (19, 0.5, 65, 50),\n    (19.5, 0.5, 64, 48),\n    (20, 1, 62, 55),\n    (21, 0.5, 64, 50),\n    (21.5, 0.5, 65, 55),\n    (22, 1, 67, 60),\n    (23, 1, 69, 58),\n    (24, 0.5, 72, 100),\n    (24.5, 0.5, 74, 95),\n    (25, 0.5, 76, 110),\n    (25.5, 0.5, 77, 115),\n    (26, 2, 79, 127),\n    (28, 1, 76, 105),\n    (29, 1, 74, 88),\n    (30, 2, 72, 110),\n]\n\nstarts, durations, pitches, velocities, roles = [], [], [], [], []\n\nfor beat, dur, chord_pitches, chord_vels in chords:\n    for p, v in zip(chord_pitches, chord_vels, strict=True):\n        starts.append(beat)\n        durations.append(dur)\n        pitches.append(p)\n        velocities.append(min(v, 127))\n        roles.append(\"Accompaniment\")\n\nfor beat, dur, pitch, vel in melody_notes:\n    starts.append(beat)\n    durations.append(dur)\n    pitches.append(pitch)\n    velocities.append(min(vel, 127))\n    roles.append(\"Melody\")\n\ndf = pd.DataFrame({\"start\": starts, \"duration\": durations, \"pitch\": pitches, \"velocity\": velocities, \"role\": roles})\ndf[\"end\"] = df[\"start\"] + df[\"duration\"]\n\n# Melody notes slightly taller for visual hierarchy\ndf[\"pitch_top\"] = np.where(df[\"role\"] == \"Melody\", df[\"pitch\"] + 0.45, df[\"pitch\"] + 0.35)\ndf[\"pitch_bottom\"] = np.where(df[\"role\"] == \"Melody\", df[\"pitch\"] - 0.45, df[\"pitch\"] - 0.35)\n\nnote_names_all = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\ndf[\"note_name\"] = [f\"{note_names_all[p % 12]}{p // 12 - 1}\" for p in df[\"pitch\"]]\n\npitch_min = df[\"pitch\"].min() - 1\npitch_max = df[\"pitch\"].max() + 1\n\n# Black key row shading across full width\nall_pitches = list(range(pitch_min, pitch_max + 1))\nblack_pitches_in_range = [p for p in all_pitches if p in black_key_pitches]\nbg_rows = pd.DataFrame(\n    {\n        \"pitch_bottom\": [p - 0.5 for p in black_pitches_in_range],\n        \"pitch_top\": [p + 0.5 for p in black_pitches_in_range],\n        \"xmin\": [0.0] * len(black_pitches_in_range),\n        \"xmax\": [32.0] * len(black_pitches_in_range),\n    }\n)\n\n# Y-axis labels — white keys only\ny_breaks = [p for p in all_pitches if p not in black_key_pitches]\nwhite_note_letters = {0: \"C\", 2: \"D\", 4: \"E\", 5: \"F\", 7: \"G\", 9: \"A\", 11: \"B\"}\ny_labels = [f\"{white_note_letters[p % 12]}{p // 12 - 1}\" for p in y_breaks]\n\nbeat_lines = pd.DataFrame({\"x\": [float(b) for b in range(0, 33)]})\nmeasure_lines = pd.DataFrame({\"x\": [float(m) for m in range(0, 33, 4)]})\n\ndf_accomp = df[df[\"role\"] == \"Accompaniment\"].copy()\ndf_melody = df[df[\"role\"] == \"Melody\"].copy()\n\nsections = pd.DataFrame(\n    {\n        \"x\": [2.0, 10.0, 18.0, 26.0],\n        \"y\": [pitch_max + 1.2] * 4,\n        \"label\": [\"pp — Building\", \"f — Response\", \"pp — Restart\", \"fff — Climax\"],\n    }\n)\n\nrole_labels = pd.DataFrame({\"x\": [31.5, 31.5], \"y\": [76.0, 52.0], \"label\": [\"Melody\", \"Accomp.\"]})\n\ntitle = \"piano-roll-midi · python · letsplot · anyplot.ai\"\n\n# Plot\nplot = (\n    ggplot()\n    # Black key row shading — stronger contrast than previous\n    + geom_rect(\n        data=bg_rows,\n        mapping=aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"pitch_bottom\", ymax=\"pitch_top\"),\n        fill=BLACK_KEY_BG,\n        color=\"rgba(0,0,0,0)\",\n        alpha=0.8,\n    )\n    # Beat grid lines (subtle)\n    + geom_vline(data=beat_lines, mapping=aes(xintercept=\"x\"), color=BEAT_LINE, size=0.3)\n    # Measure grid lines (stronger — mark bars)\n    + geom_vline(data=measure_lines, mapping=aes(xintercept=\"x\"), color=MEASURE_LINE, size=0.8)\n    # Accompaniment notes — semi-transparent\n    + geom_rect(\n        data=df_accomp,\n        mapping=aes(xmin=\"start\", xmax=\"end\", ymin=\"pitch_bottom\", ymax=\"pitch_top\", fill=\"velocity\"),\n        color=PAGE_BG,\n        size=0.3,\n        alpha=0.80,\n        tooltips=layer_tooltips().line(\"@note_name\").line(\"vel: @velocity\").line(\"beat: @start — @end\"),\n    )\n    # Melody notes — fully opaque, taller, dark border for hierarchy\n    + geom_rect(\n        data=df_melody,\n        mapping=aes(xmin=\"start\", xmax=\"end\", ymin=\"pitch_bottom\", ymax=\"pitch_top\", fill=\"velocity\"),\n        color=INK,\n        size=0.5,\n        alpha=1.0,\n        tooltips=layer_tooltips().line(\"@note_name\").line(\"vel: @velocity\").line(\"beat: @start — @end\"),\n    )\n    # Section labels showing dynamic arc (pp → fff)\n    + geom_text(data=sections, mapping=aes(x=\"x\", y=\"y\", label=\"label\"), size=4, color=INK_MUTED, fontface=\"italic\")\n    # Role labels on right edge\n    + geom_text(data=role_labels, mapping=aes(x=\"x\", y=\"y\", label=\"label\"), size=3.5, color=INK_SOFT, fontface=\"bold\")\n    # Imprint sequential colormap — quiet (green) → loud (blue), single-polarity\n    + scale_fill_gradient(\n        low=\"#009E73\",\n        high=\"#4467A3\",\n        name=\"Velocity\",\n        limits=[30, 127],\n        guide=guide_colorbar(barwidth=8, barheight=140),\n    )\n    + scale_x_continuous(name=\"Time (beats)\", breaks=[0, 4, 8, 12, 16, 20, 24, 28, 32])\n    + scale_y_continuous(name=\"Pitch\", breaks=y_breaks, labels=y_labels)\n    + coord_cartesian(xlim=[-0.5, 33], ylim=[pitch_min - 0.5, pitch_max + 2.5])\n    + labs(title=title)\n    + theme_minimal()\n    + theme(\n        plot_title=element_text(size=16, face=\"bold\", color=INK),\n        axis_title_x=element_text(size=12, color=INK),\n        axis_title_y=element_text(size=12, color=INK),\n        axis_text_x=element_text(size=10, color=INK_SOFT),\n        axis_text_y=element_text(size=11, color=INK_SOFT),\n        axis_line=element_line(color=INK_SOFT, size=0.5),\n        axis_ticks=element_line(color=INK_SOFT, size=0.3),\n        legend_title=element_text(size=10, color=INK),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_position=\"right\",\n        panel_grid_major=element_blank(),\n        panel_grid_minor=element_blank(),\n        panel_background=element_rect(fill=PAGE_BG, color=INK_SOFT, size=0.3),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    )\n    + ggsize(800, 450)\n)\n\n# Save\nexport_ggsave(plot, filename=f\"plot-{THEME}.png\", path=\".\", scale=4)\nexport_ggsave(plot, filename=f\"plot-{THEME}.html\", path=\".\")\n\nif os.path.exists(\"lets-plot-images\"):\n    shutil.rmtree(\"lets-plot-images\")\n"}