{"spec_id":"piano-roll-midi","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\npiano-roll-midi: MIDI Piano Roll Visualization\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-03\n\"\"\"\n\nimport io\nimport os\nimport sys\n\n\n# Prevent bokeh.py from shadowing the installed bokeh package\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.realpath(p) != os.path.realpath(_this_dir)]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColorBar, ColumnDataSource, FixedTicker, HoverTool, LinearColorMapper, Range1d\nfrom bokeh.plotting import figure\nfrom PIL import Image\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\n# imprint_seq: brand green (#009E73) → blue (#4467A3) — 256-stop pre-computed inline\nVELOCITY_PALETTE = [\n    f\"#{round(68 * t / 255):02X}{round(158 - 55 * t / 255):02X}{round(115 + 48 * t / 255):02X}\" for t in range(256)\n]\n\n# Data\nnote_names_map = {0: \"C\", 1: \"C#\", 2: \"D\", 3: \"D#\", 4: \"E\", 5: \"F\", 6: \"F#\", 7: \"G\", 8: \"G#\", 9: \"A\", 10: \"A#\", 11: \"B\"}\nblack_key_indices = {1, 3, 6, 8, 10}\nnote_names = [f\"{note_names_map[p % 12]}{p // 12 - 1}\" for p in range(128)]\nblack_keys = {p for p in range(128) if (p % 12) in black_key_indices}\n\n# Musical phrase: C major scale runs + I-IV-V-I chord progression + melodic resolution\nnotes = []\n\n# Measures 1-2: ascending C major scale with crescendo\nfor i, pitch in enumerate([60, 62, 64, 65, 67, 69, 71, 72]):\n    notes.append({\"start\": i * 0.5, \"duration\": 0.45, \"pitch\": pitch, \"velocity\": 55 + i * 7})\n\n# Measures 3-4: descending scale with decrescendo\nfor i, pitch in enumerate([72, 71, 69, 67, 65, 64, 62, 60]):\n    notes.append({\"start\": 4.0 + i * 0.5, \"duration\": 0.45, \"pitch\": pitch, \"velocity\": 100 - i * 6})\n\n# Measures 5-6: block chords building to fortissimo climax (I-IV-V-I)\nfor start, dur, pitch, vel in [\n    (8.0, 1.0, 60, 85),\n    (8.0, 1.0, 64, 80),\n    (8.0, 1.0, 67, 75),\n    (9.0, 1.0, 65, 95),\n    (9.0, 1.0, 69, 90),\n    (9.0, 1.0, 72, 85),\n    (10.0, 1.0, 67, 120),\n    (10.0, 1.0, 71, 118),\n    (10.0, 1.0, 74, 115),\n    (11.0, 2.0, 60, 100),\n    (11.0, 2.0, 64, 95),\n    (11.0, 2.0, 67, 90),\n    (11.0, 2.0, 72, 85),\n]:\n    notes.append({\"start\": start, \"duration\": dur, \"pitch\": pitch, \"velocity\": vel})\n\n# Measures 7-8: melodic phrase resolving gently\nfor start, dur, pitch, vel in [\n    (13.0, 0.5, 72, 95),\n    (13.5, 0.25, 74, 80),\n    (13.75, 0.25, 72, 75),\n    (14.0, 0.5, 71, 85),\n    (14.5, 0.5, 69, 80),\n    (15.0, 1.0, 67, 65),\n    (15.0, 1.0, 60, 60),\n]:\n    notes.append({\"start\": start, \"duration\": dur, \"pitch\": pitch, \"velocity\": vel})\n\nstarts = np.array([n[\"start\"] for n in notes])\ndurations = np.array([n[\"duration\"] for n in notes])\npitches = np.array([n[\"pitch\"] for n in notes])\nvelocities = np.array([n[\"velocity\"] for n in notes])\n\nrect_x = starts + durations / 2\nrect_y = pitches.astype(float)\nrect_w = durations\nrect_h = np.full_like(durations, 0.82)\n\npitch_min = int(pitches.min()) - 1\npitch_max = int(pitches.max()) + 1\n\n# Background key shading — theme-adaptive alternating rows for piano keyboard layout\nif THEME == \"light\":\n    white_key_color = \"#FFFDF6\"\n    black_key_color = \"#E5E1D8\"\nelse:\n    white_key_color = \"#262521\"\n    black_key_color = \"#131310\"\n\nbg_pitches = list(range(pitch_min, pitch_max + 1))\nbg_source = ColumnDataSource(\n    data={\n        \"x\": [8.0] * len(bg_pitches),\n        \"y\": [float(p) for p in bg_pitches],\n        \"w\": [17.0] * len(bg_pitches),\n        \"h\": [1.0] * len(bg_pitches),\n        \"color\": [black_key_color if p in black_keys else white_key_color for p in bg_pitches],\n    }\n)\n\ncolor_mapper = LinearColorMapper(palette=VELOCITY_PALETTE, low=40, high=127)\n\nnote_source = ColumnDataSource(\n    data={\n        \"x\": rect_x,\n        \"y\": rect_y,\n        \"w\": rect_w,\n        \"h\": rect_h,\n        \"velocity\": velocities,\n        \"pitch\": pitches,\n        \"note_name\": [note_names[p] for p in pitches],\n        \"start\": starts,\n        \"duration\": durations,\n    }\n)\n\n# Title (45 chars < 67 baseline — use default 50pt)\ntitle = \"piano-roll-midi · python · bokeh · anyplot.ai\"\n\n# Plot — canvas 3200×1800, toolbar_location=None prevents extra height in PNG\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_axis_label=\"Time (beats)\",\n    y_axis_label=\"Pitch\",\n    x_range=Range1d(-0.5, 16.5),\n    y_range=Range1d(pitch_min - 0.5, pitch_max + 0.5),\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=80,\n)\n\n# Background piano key rows\np.rect(x=\"x\", y=\"y\", width=\"w\", height=\"h\", source=bg_source, fill_color=\"color\", line_color=None, level=\"underlay\")\n\n# Custom beat/measure grid lines — measure boundaries stronger than beat lines\nfor beat in range(17):\n    is_measure = beat % 4 == 0\n    p.line(\n        [beat, beat],\n        [pitch_min - 0.5, pitch_max + 0.5],\n        line_color=INK_SOFT,\n        line_alpha=0.40 if is_measure else 0.12,\n        line_width=2.5 if is_measure else 1.0,\n    )\n\n# Note rectangles colored by velocity\np.rect(\n    x=\"x\",\n    y=\"y\",\n    width=\"w\",\n    height=\"h\",\n    source=note_source,\n    fill_color={\"field\": \"velocity\", \"transform\": color_mapper},\n    line_color=PAGE_BG,\n    line_width=2,\n    line_alpha=0.9,\n)\n\n# Hover tooltip for interactive HTML\nhover = HoverTool(\n    tooltips=[\n        (\"Note\", \"@note_name\"),\n        (\"Start\", \"@start{0.00} beats\"),\n        (\"Duration\", \"@duration{0.00} beats\"),\n        (\"Velocity\", \"@velocity\"),\n    ]\n)\np.add_tools(hover)\n\n# Velocity color bar\ncolor_bar = ColorBar(\n    color_mapper=color_mapper,\n    label_standoff=14,\n    width=40,\n    location=(0, 0),\n    title=\"Velocity\",\n    title_text_font_size=\"34pt\",\n    title_text_color=INK,\n    major_label_text_font_size=\"28pt\",\n    major_label_text_color=INK_SOFT,\n    ticker=FixedTicker(ticks=[40, 60, 80, 100, 120]),\n    background_fill_color=PAGE_BG,\n    border_line_color=None,\n)\np.add_layout(color_bar, \"right\")\n\n# Style — text sizes per bokeh sizing guide\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"normal\"\np.title.text_color = INK\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\n\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\n# Y-axis: note names instead of raw MIDI numbers\ny_ticks = list(range(pitch_min, pitch_max + 1))\np.yaxis.ticker = FixedTicker(ticks=y_ticks)\np.yaxis.major_label_overrides = {p_val: note_names[p_val] for p_val in y_ticks}\n\n# X-axis: integer beat numbers\np.xaxis.ticker = FixedTicker(ticks=list(range(17)))\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\n\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = None\n\n# Save interactive HTML artifact\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome — window is H+200 tall so bokeh canvas fills\n# exactly W×H; PIL crops to the target rect before saving.\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H + 200}\",\n    \"--hide-scrollbars\",\n    \"--force-device-scale-factor=1\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H + 200)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw = driver.get_screenshot_as_png()\ndriver.quit()\nImage.open(io.BytesIO(raw)).crop((0, 0, W, H)).save(f\"plot-{THEME}.png\")\n"}