{"spec_id":"piano-roll-midi","library":"altair","language":"python","code":"\"\"\" anyplot.ai\npiano-roll-midi: MIDI Piano Roll Visualization\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file from shadowing the installed altair package when run from its own directory\n_thisdir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _thisdir]\ndel _thisdir\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\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# Piano key row shading (theme-adaptive alternating background)\nWHITE_KEY_BG = \"#F0EDE6\" if THEME == \"light\" else \"#242420\"\nBLACK_KEY_BG = \"#D8D4CC\" if THEME == \"light\" else \"#1A1A17\"\n\n# Data: C major progression with melody over 8 measures (32 beats)\nnp.random.seed(42)\nNOTE_NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\n\nnotes = []\n\n# Bass line (A2–F3)\nbass_pattern = [\n    (0, 2, 48),\n    (2, 2, 52),\n    (4, 2, 53),\n    (6, 2, 48),\n    (8, 2, 50),\n    (10, 2, 48),\n    (12, 2, 45),\n    (14, 2, 47),\n    (16, 2, 48),\n    (18, 2, 52),\n    (20, 2, 53),\n    (22, 2, 48),\n    (24, 2, 50),\n    (26, 2, 48),\n    (28, 2, 47),\n    (30, 2, 48),\n]\nfor start, dur, pitch in bass_pattern:\n    notes.append({\"start\": start, \"duration\": dur, \"pitch\": pitch, \"velocity\": np.random.randint(60, 80)})\n\n# Chord voicings (B3–A4)\nchord_hits = [\n    (0, [60, 64, 67]),\n    (4, [60, 65, 69]),\n    (8, [62, 65, 69]),\n    (12, [59, 62, 67]),\n    (16, [60, 64, 67]),\n    (20, [60, 65, 69]),\n    (24, [62, 65, 69]),\n    (28, [59, 64, 67]),\n]\nfor start, pitches in chord_hits:\n    for p in pitches:\n        notes.append({\"start\": start, \"duration\": 3.5, \"pitch\": p, \"velocity\": np.random.randint(50, 75)})\n\n# Melody (B4–G5, varying rhythms and dynamics)\nmelody = [\n    (0, 1, 72, 100),\n    (1, 0.5, 74, 90),\n    (1.5, 0.5, 76, 85),\n    (2, 1, 77, 105),\n    (3, 1, 76, 95),\n    (4, 1.5, 74, 100),\n    (5.5, 0.5, 72, 80),\n    (6, 1, 71, 90),\n    (7, 0.5, 72, 85),\n    (7.5, 0.5, 74, 80),\n    (8, 2, 76, 110),\n    (10, 1, 74, 90),\n    (11, 1, 72, 85),\n    (12, 1.5, 71, 95),\n    (13.5, 0.5, 72, 80),\n    (14, 1, 74, 100),\n    (15, 1, 76, 95),\n    (16, 1, 77, 115),\n    (17, 0.5, 79, 100),\n    (17.5, 0.5, 77, 90),\n    (18, 1, 76, 105),\n    (19, 0.5, 74, 85),\n    (19.5, 0.5, 72, 80),\n    (20, 1.5, 74, 100),\n    (21.5, 0.5, 76, 90),\n    (22, 2, 77, 110),\n    (24, 1, 79, 120),\n    (25, 1, 77, 105),\n    (26, 1, 76, 100),\n    (27, 1, 74, 90),\n    (28, 1.5, 72, 95),\n    (29.5, 0.5, 74, 85),\n    (30, 2, 72, 110),\n]\nfor start, dur, pitch, vel in melody:\n    notes.append({\"start\": start, \"duration\": dur, \"pitch\": pitch, \"velocity\": vel})\n\ndf = pd.DataFrame(notes)\ndf[\"end\"] = df[\"start\"] + df[\"duration\"]\ndf[\"note_name\"] = df[\"pitch\"].apply(lambda p: f\"{NOTE_NAMES[p % 12]}{p // 12 - 1}\")\n\n# Display pitch range: used pitches ± 1 semitone for piano key context\nused_pitches = set(df[\"pitch\"].unique())\npitch_min = df[\"pitch\"].min() - 1\npitch_max = df[\"pitch\"].max() + 1\ndisplay_pitches = set()\nfor p in used_pitches:\n    display_pitches.update([p - 1, p, p + 1])\nall_pitches = sorted([p for p in display_pitches if pitch_min <= p <= pitch_max])\n\n# Sort order: low pitch at bottom (descending list for Altair nominal axis)\npitch_labels = [f\"{NOTE_NAMES[p % 12]}{p // 12 - 1}\" for p in reversed(all_pitches)]\n\n# Piano key background rows (black semitones: C#, D#, F#, G#, A#)\nBLACK_SEMITONES = {1, 3, 6, 8, 10}\nbg_rows = [\n    {\n        \"pitch\": p,\n        \"note_name\": f\"{NOTE_NAMES[p % 12]}{p // 12 - 1}\",\n        \"is_black\": (p % 12) in BLACK_SEMITONES,\n        \"start\": 0.0,\n        \"end\": 32.0,\n    }\n    for p in all_pitches\n]\nbg_df = pd.DataFrame(bg_rows)\n\n# Musical layer classification for legend-bound selection\ndf[\"layer\"] = df[\"pitch\"].apply(lambda p: \"Bass\" if p < 55 else (\"Chords\" if p < 70 else \"Melody\"))\n\n# Selections: layer toggle (legend) + measure brush (HTML only)\nlayer_selection = alt.selection_point(fields=[\"layer\"], bind=\"legend\")\nbrush = alt.selection_interval(encodings=[\"x\"])\n\n# Layer 1: Alternating piano key row shading\nbackground = (\n    alt.Chart(bg_df)\n    .mark_bar()\n    .encode(\n        x=alt.X(\"start:Q\", scale=alt.Scale(domain=[0, 32])),\n        x2=\"end:Q\",\n        y=alt.Y(\"note_name:N\", sort=pitch_labels),\n        color=alt.condition(alt.datum.is_black, alt.value(BLACK_KEY_BG), alt.value(WHITE_KEY_BG)),\n    )\n)\n\n# Layer 2: Beat grid lines (dashed, subtle)\nbeat_positions = pd.DataFrame({\"beat\": list(range(33))})\nbeat_grid = (\n    alt.Chart(beat_positions).mark_rule(strokeDash=[3, 3], opacity=0.25, color=INK_SOFT).encode(x=alt.X(\"beat:Q\"))\n)\n\n# Layer 3: Measure boundary lines (solid, stronger)\nmeasure_positions = pd.DataFrame({\"beat\": list(range(0, 33, 4))})\nmeasure_grid = alt.Chart(measure_positions).mark_rule(opacity=0.5, color=INK, strokeWidth=1.5).encode(x=alt.X(\"beat:Q\"))\n\n# Layer 4: Note bars — Imprint sequential velocity (soft=green #009E73, loud=blue #4467A3)\nnote_bars = (\n    alt.Chart(df)\n    .mark_bar(cornerRadius=3, stroke=INK, strokeWidth=0.5)\n    .encode(\n        x=alt.X(\n            \"start:Q\",\n            title=\"Measure\",\n            axis=alt.Axis(\n                labelFontSize=10,\n                titleFontSize=12,\n                values=list(range(0, 33, 4)),\n                labelExpr=\"'M' + (datum.value / 4 + 1)\",\n            ),\n        ),\n        x2=\"end:Q\",\n        y=alt.Y(\"note_name:N\", sort=pitch_labels, title=\"Pitch\", axis=alt.Axis(labelFontSize=9, titleFontSize=12)),\n        color=alt.Color(\n            \"velocity:Q\",\n            title=\"Velocity\",\n            scale=alt.Scale(range=[\"#009E73\", \"#4467A3\"], domain=[40, 127]),\n            legend=alt.Legend(titleFontSize=10, labelFontSize=9, orient=\"right\", gradientLength=150),\n        ),\n        opacity=alt.condition(layer_selection, alt.value(0.95), alt.value(0.15)),\n        tooltip=[\n            alt.Tooltip(\"note_name:N\", title=\"Note\"),\n            alt.Tooltip(\"layer:N\", title=\"Layer\"),\n            alt.Tooltip(\"start:Q\", title=\"Start (beat)\"),\n            alt.Tooltip(\"duration:Q\", title=\"Duration\"),\n            alt.Tooltip(\"velocity:Q\", title=\"Velocity\"),\n        ],\n    )\n    .add_params(layer_selection)\n)\n\n# Layer 5: Musical layer labels at right margin\nlayer_label_data = pd.DataFrame(\n    [\n        {\"x\": 32.3, \"note_name\": \"C3\", \"label\": \"Bass\"},\n        {\"x\": 32.3, \"note_name\": \"E4\", \"label\": \"Chords\"},\n        {\"x\": 32.3, \"note_name\": \"E5\", \"label\": \"Melody\"},\n    ]\n)\nlayer_labels = (\n    alt.Chart(layer_label_data)\n    .mark_text(align=\"left\", dx=8, fontSize=9, fontWeight=\"bold\", color=INK_MUTED, fontStyle=\"italic\")\n    .encode(x=alt.X(\"x:Q\"), y=alt.Y(\"note_name:N\", sort=pitch_labels), text=\"label:N\")\n)\n\n# Title: 46 chars < 67 baseline → fontSize=16 (no scaling needed)\ntitle = \"piano-roll-midi · python · altair · anyplot.ai\"\n\n# Compose all five layers with full theme-adaptive configuration\nchart = (\n    (background + beat_grid + measure_grid + note_bars + layer_labels)\n    .properties(\n        width=620, height=330, background=PAGE_BG, title=alt.Title(text=title, fontSize=16, anchor=\"middle\", color=INK)\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(grid=False, domainColor=INK_SOFT, tickColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG and pad to exact canvas target (3200 × 1800)\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        \"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save interactive HTML (adds measure brush selection)\nchart.add_params(brush).save(f\"plot-{THEME}.html\")\n"}