{"spec_id":"piano-roll-midi","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\npiano-roll-midi: MIDI Piano Roll Visualization\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport re\nimport sys\n\n\n# This file is named pygal.py; remove its directory from sys.path so the\n# import below resolves to the installed pygal package, not this file.\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if p not in (\"\", _here)]\n\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nROW_SHADE = \"#E5E0D0\" if THEME == \"light\" else \"#252521\"  # black-key row tint\n\n# Velocity colormap: Imprint soft(blue)→loud(red) matches spec \"piano→forte\" semantic\nVELOCITY_COLORS = (\"#4467A3\", \"#2ABCCD\", \"#009E73\", \"#BD8233\", \"#AE3030\")\n\n# Data: D-minor jazz ii–V–I–vi (Dm7–G7–Cmaj7–Am7), 8 measures, 4/4 time\n# Three layers: bass (bottom), chord voicings (middle), melody (top)\nnotes = [\n    # M1-2: Dm7 — bass D3, chord D4/F4/A4, melody D5→F5\n    {\"start\": 0, \"duration\": 4, \"pitch\": 50, \"velocity\": 70},\n    {\"start\": 4, \"duration\": 4, \"pitch\": 50, \"velocity\": 65},\n    {\"start\": 0, \"duration\": 2, \"pitch\": 62, \"velocity\": 75},\n    {\"start\": 0, \"duration\": 2, \"pitch\": 65, \"velocity\": 72},\n    {\"start\": 0, \"duration\": 2, \"pitch\": 69, \"velocity\": 72},\n    {\"start\": 2, \"duration\": 2, \"pitch\": 62, \"velocity\": 70},\n    {\"start\": 2, \"duration\": 2, \"pitch\": 65, \"velocity\": 68},\n    {\"start\": 4, \"duration\": 2, \"pitch\": 62, \"velocity\": 73},\n    {\"start\": 4, \"duration\": 2, \"pitch\": 65, \"velocity\": 70},\n    {\"start\": 6, \"duration\": 2, \"pitch\": 62, \"velocity\": 68},\n    {\"start\": 6, \"duration\": 2, \"pitch\": 65, \"velocity\": 65},\n    {\"start\": 0, \"duration\": 1, \"pitch\": 74, \"velocity\": 95},\n    {\"start\": 1, \"duration\": 1, \"pitch\": 72, \"velocity\": 88},\n    {\"start\": 2, \"duration\": 2, \"pitch\": 69, \"velocity\": 100},\n    {\"start\": 4, \"duration\": 1.5, \"pitch\": 72, \"velocity\": 105},\n    {\"start\": 5.5, \"duration\": 0.5, \"pitch\": 74, \"velocity\": 88},\n    {\"start\": 6, \"duration\": 2, \"pitch\": 77, \"velocity\": 110},\n    # M3-4: G7 — bass G3, chord B3/D4/F4, melody climbs to F5 (dominant tension)\n    {\"start\": 8, \"duration\": 4, \"pitch\": 55, \"velocity\": 72},\n    {\"start\": 12, \"duration\": 4, \"pitch\": 55, \"velocity\": 67},\n    {\"start\": 8, \"duration\": 2, \"pitch\": 59, \"velocity\": 78},\n    {\"start\": 8, \"duration\": 2, \"pitch\": 62, \"velocity\": 75},\n    {\"start\": 8, \"duration\": 2, \"pitch\": 65, \"velocity\": 73},\n    {\"start\": 10, \"duration\": 2, \"pitch\": 59, \"velocity\": 73},\n    {\"start\": 10, \"duration\": 2, \"pitch\": 62, \"velocity\": 70},\n    {\"start\": 12, \"duration\": 2, \"pitch\": 59, \"velocity\": 76},\n    {\"start\": 12, \"duration\": 2, \"pitch\": 62, \"velocity\": 73},\n    {\"start\": 14, \"duration\": 2, \"pitch\": 59, \"velocity\": 70},\n    {\"start\": 14, \"duration\": 2, \"pitch\": 62, \"velocity\": 68},\n    {\"start\": 8, \"duration\": 1, \"pitch\": 77, \"velocity\": 105},\n    {\"start\": 9, \"duration\": 0.5, \"pitch\": 76, \"velocity\": 92},\n    {\"start\": 9.5, \"duration\": 0.5, \"pitch\": 74, \"velocity\": 87},\n    {\"start\": 10, \"duration\": 2, \"pitch\": 72, \"velocity\": 100},\n    {\"start\": 12, \"duration\": 1, \"pitch\": 74, \"velocity\": 108},\n    {\"start\": 13, \"duration\": 1, \"pitch\": 76, \"velocity\": 95},\n    {\"start\": 14, \"duration\": 2, \"pitch\": 77, \"velocity\": 112},\n    # M5-6: Cmaj7 — bass C3, chord C4/E4/G4, melody resolves E5→B4\n    {\"start\": 16, \"duration\": 4, \"pitch\": 48, \"velocity\": 75},\n    {\"start\": 20, \"duration\": 4, \"pitch\": 48, \"velocity\": 70},\n    {\"start\": 16, \"duration\": 2, \"pitch\": 60, \"velocity\": 80},\n    {\"start\": 16, \"duration\": 2, \"pitch\": 64, \"velocity\": 77},\n    {\"start\": 16, \"duration\": 2, \"pitch\": 67, \"velocity\": 77},\n    {\"start\": 18, \"duration\": 2, \"pitch\": 60, \"velocity\": 75},\n    {\"start\": 18, \"duration\": 2, \"pitch\": 64, \"velocity\": 73},\n    {\"start\": 20, \"duration\": 2, \"pitch\": 60, \"velocity\": 78},\n    {\"start\": 20, \"duration\": 2, \"pitch\": 64, \"velocity\": 75},\n    {\"start\": 22, \"duration\": 2, \"pitch\": 60, \"velocity\": 72},\n    {\"start\": 22, \"duration\": 2, \"pitch\": 64, \"velocity\": 70},\n    {\"start\": 16, \"duration\": 2, \"pitch\": 76, \"velocity\": 100},\n    {\"start\": 18, \"duration\": 1, \"pitch\": 74, \"velocity\": 92},\n    {\"start\": 19, \"duration\": 1, \"pitch\": 72, \"velocity\": 88},\n    {\"start\": 20, \"duration\": 2, \"pitch\": 71, \"velocity\": 95},\n    {\"start\": 22, \"duration\": 1, \"pitch\": 72, \"velocity\": 90},\n    {\"start\": 23, \"duration\": 1, \"pitch\": 74, \"velocity\": 87},\n    # M7-8: Am7 — bass A2, chord A3/C4/E4, melody winds down to D4\n    {\"start\": 24, \"duration\": 4, \"pitch\": 45, \"velocity\": 72},\n    {\"start\": 28, \"duration\": 4, \"pitch\": 45, \"velocity\": 68},\n    {\"start\": 24, \"duration\": 2, \"pitch\": 57, \"velocity\": 78},\n    {\"start\": 24, \"duration\": 2, \"pitch\": 60, \"velocity\": 75},\n    {\"start\": 24, \"duration\": 2, \"pitch\": 64, \"velocity\": 75},\n    {\"start\": 26, \"duration\": 2, \"pitch\": 57, \"velocity\": 73},\n    {\"start\": 26, \"duration\": 2, \"pitch\": 60, \"velocity\": 72},\n    {\"start\": 28, \"duration\": 2, \"pitch\": 57, \"velocity\": 76},\n    {\"start\": 28, \"duration\": 2, \"pitch\": 60, \"velocity\": 73},\n    {\"start\": 30, \"duration\": 2, \"pitch\": 57, \"velocity\": 70},\n    {\"start\": 30, \"duration\": 2, \"pitch\": 60, \"velocity\": 68},\n    {\"start\": 24, \"duration\": 1.5, \"pitch\": 72, \"velocity\": 95},\n    {\"start\": 25.5, \"duration\": 0.5, \"pitch\": 71, \"velocity\": 83},\n    {\"start\": 26, \"duration\": 1, \"pitch\": 69, \"velocity\": 88},\n    {\"start\": 27, \"duration\": 1, \"pitch\": 67, \"velocity\": 82},\n    {\"start\": 28, \"duration\": 2, \"pitch\": 65, \"velocity\": 78},\n    {\"start\": 30, \"duration\": 2, \"pitch\": 62, \"velocity\": 75},\n]\n\nCHROMATIC = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\nBLACK_KEYS = {1, 3, 6, 8, 10}\n\nmin_pitch = min(n[\"pitch\"] for n in notes)\nmax_pitch = max(n[\"pitch\"] for n in notes)\npitch_range = list(range(min_pitch, max_pitch + 1))\npitch_names = {p: f\"{CHROMATIC[p % 12]}{p // 12 - 1}\" for p in pitch_range}\ntotal_beats = max(n[\"start\"] + n[\"duration\"] for n in notes)\n\nvelocity_bands = [\n    (\"pp (65-74)\", 65, 74),\n    (\"p (75-84)\", 75, 84),\n    (\"mp (85-94)\", 85, 94),\n    (\"f (95-104)\", 95, 104),\n    (\"ff (105-115)\", 105, 115),\n]\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=VELOCITY_COLORS,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=38,\n    font_family=\"'DejaVu Sans', Arial, sans-serif\",\n)\n\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"piano-roll-midi · python · pygal · anyplot.ai\",\n    x_title=\"Time (beats)\",\n    y_title=\"Pitch\",\n    show_x_guides=True,\n    show_y_guides=True,\n    show_dots=False,\n    stroke=True,\n    fill=False,\n    allow_interruptions=True,\n    margin_left=140,\n    margin_right=60,\n    margin_top=80,\n    margin_bottom=130,\n    legend_at_bottom=True,\n    legend_box_size=36,\n    x_labels=list(range(0, int(total_beats) + 1, 4)),\n    truncate_legend=-1,\n)\n\nchart.y_labels = [{\"value\": p, \"label\": pitch_names[p]} for p in pitch_range]\n\nfor band_name, v_lo, v_hi in velocity_bands:\n    pts = []\n    for n in notes:\n        if v_lo <= n[\"velocity\"] <= v_hi:\n            pts.append((n[\"start\"], n[\"pitch\"]))\n            pts.append((n[\"start\"] + n[\"duration\"], n[\"pitch\"]))\n            pts.append(None)\n    chart.add(band_name, pts if pts else [])\n\n# SVG post-processing: inject grid layers and row shading behind the data\nsvg_bytes = chart.render()\nsvg_str = svg_bytes.decode(\"utf-8\")\n\ntry:\n    h_guides = []  # (y, width) — horizontal pitch-row guides\n    v_guides = []  # (x, y0, height) — vertical measure guides\n\n    for m in re.finditer(r\"<path\\b([^>]+)>\", svg_str):\n        tag = m.group(1)\n        if \"guide\" not in tag:\n            continue\n        # Horizontal guide: M{x} {y} h{w}\n        hm = re.search(r'\\bd=\"M[\\d.]+\\s+([\\d.]+)\\s+h([\\d.]+)\"', tag)\n        if hm:\n            y, w = float(hm.group(1)), float(hm.group(2))\n            if w > 200:\n                h_guides.append((y, w))\n        # Vertical guide: M{x} {y0} v{h}  OR  M{x} {y0} L{x} {y1}\n        vm = re.search(r'\\bd=\"M([\\d.]+)\\s+([\\d.]+)\\s+(?:v([\\d.]+)|L[\\d.]+\\s+([\\d.]+))\"', tag)\n        if vm:\n            x, y0 = float(vm.group(1)), float(vm.group(2))\n            h = float(vm.group(3)) if vm.group(3) else float(vm.group(4)) - y0\n            if h > 200:\n                v_guides.append((x, y0, h))\n\n    h_guides = sorted({(round(y, 3), w) for y, w in h_guides}, key=lambda t: t[0])\n    v_guides = sorted({(round(x, 1), round(y0, 1), round(h, 1)) for x, y0, h in v_guides}, key=lambda t: t[0])\n\n    inject_parts = []\n\n    # 1. Black-key row shading\n    if len(h_guides) == len(pitch_range) and len(h_guides) > 1:\n        pitch_sorted = sorted(pitch_range)\n        y_map = {pitch_sorted[-(i + 1)]: h_guides[i][0] for i in range(len(pitch_sorted))}\n        plot_w = h_guides[0][1]\n        row_h = abs(h_guides[1][0] - h_guides[0][0])\n        for p in pitch_range:\n            if p % 12 in BLACK_KEYS and p in y_map:\n                inject_parts.append(\n                    f'<rect x=\"0\" y=\"{y_map[p] - row_h / 2:.3f}\" '\n                    f'width=\"{plot_w:.1f}\" height=\"{row_h:.3f}\" '\n                    f'fill=\"{ROW_SHADE}\"/>'\n                )\n\n    # 2. G7 dominant climax highlight (measures 3-4, beats 8-16): amber wash\n    # Emphasizes the harmonic tension peak of the ii-V-I-vi progression\n    if len(v_guides) >= 5:\n        gy0, gh = v_guides[0][1], v_guides[0][2]\n        cx1, cx2 = v_guides[2][0], v_guides[4][0]\n        climax_fill = \"rgba(221,204,119,0.10)\" if THEME == \"light\" else \"rgba(221,204,119,0.07)\"\n        inject_parts.append(\n            f'<rect x=\"{cx1:.2f}\" y=\"{gy0:.2f}\" width=\"{cx2 - cx1:.2f}\" height=\"{gh:.2f}\" fill=\"{climax_fill}\"/>'\n        )\n\n    # 3. Beat subdivision lines (quarter-note grid, thinner/muted vs measure lines)\n    BEAT_COLOR = \"#C0B8A8\" if THEME == \"light\" else \"#3C3C37\"\n    if len(v_guides) >= 2:\n        dx_per_measure = v_guides[1][0] - v_guides[0][0]\n        dx_per_beat = dx_per_measure / 4\n        gy0, gh = v_guides[0][1], v_guides[0][2]\n        for i, (mx, _, _) in enumerate(v_guides):\n            if i < len(v_guides) - 1:\n                for b in range(1, 4):\n                    bx = mx + b * dx_per_beat\n                    inject_parts.append(\n                        f'<line x1=\"{bx:.2f}\" y1=\"{gy0:.2f}\" x2=\"{bx:.2f}\" y2=\"{gy0 + gh:.2f}\" '\n                        f'stroke=\"{BEAT_COLOR}\" stroke-width=\"1.5\" stroke-dasharray=\"6,4\"/>'\n                    )\n\n    # 4. Measure boundary lines re-injected above row shading (stronger than beat lines)\n    if len(v_guides) >= 1:\n        gy0, gh = v_guides[0][1], v_guides[0][2]\n        for mx, _, _ in v_guides:\n            inject_parts.append(\n                f'<line x1=\"{mx:.2f}\" y1=\"{gy0:.2f}\" x2=\"{mx:.2f}\" y2=\"{gy0 + gh:.2f}\" '\n                f'stroke=\"{INK_MUTED}\" stroke-width=\"2.5\"/>'\n            )\n\n    if inject_parts:\n        inject_svg = \"\\n\".join(inject_parts) + \"\\n\"\n        svg_str = re.sub(\n            r'(<g\\b[^>]*\\bclass=\"[^\"]*\\bserie\\b[^\"]*\"[^>]*>)', lambda mo: inject_svg + mo.group(1), svg_str, count=1\n        )\n\nexcept Exception:\n    pass  # graceful degradation: render without custom grid/shading\n\nimport cairosvg\n\n\ncairosvg.svg2png(bytestring=svg_str.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\")\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(svg_str)\n"}