{"spec_id":"heatmap-chromagram","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nheatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 87/100 | Updated: 2026-06-24\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\n\n# Remove script directory from sys.path so `altair` resolves to the package, not this file\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nalt = importlib.import_module(\"altair\")\nnp = importlib.import_module(\"numpy\")\npd = importlib.import_module(\"pandas\")\nImage = importlib.import_module(\"PIL.Image\")\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# Imprint sequential colormap for continuous single-polarity energy data\nIMPRINT_SEQ = [\"#009E73\", \"#4467A3\"]\n\n# Data - simulate a chromagram with chord progressions\nnp.random.seed(42)\n\npitch_classes = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\nn_frames = 120\ntime_seconds = np.linspace(0, 24, n_frames)\nframe_width = time_seconds[1] - time_seconds[0]\n\n# Chord templates (relative energy per pitch class)\nc_major = np.array([1.0, 0.0, 0.1, 0.0, 0.8, 0.1, 0.0, 0.7, 0.0, 0.05, 0.0, 0.05])\ng_major = np.array([0.1, 0.0, 0.15, 0.0, 0.05, 0.1, 0.0, 1.0, 0.0, 0.05, 0.0, 0.7])\na_minor = np.array([0.7, 0.0, 0.1, 0.0, 0.8, 0.1, 0.0, 0.05, 0.0, 1.0, 0.0, 0.05])\nf_major = np.array([0.8, 0.0, 0.05, 0.0, 0.1, 1.0, 0.0, 0.05, 0.0, 0.7, 0.0, 0.05])\n\n# Build chromagram: cycle through C -> G -> Am -> F progression\nenergy = np.zeros((12, n_frames))\nchord_sequence = [c_major, g_major, a_minor, f_major]\nframes_per_chord = n_frames // len(chord_sequence)\n\nfor i, chord in enumerate(chord_sequence):\n    start = i * frames_per_chord\n    end = start + frames_per_chord if i < len(chord_sequence) - 1 else n_frames\n    for j in range(start, end):\n        blend = np.random.uniform(0.7, 1.0)\n        noise = np.random.uniform(0.0, 0.15, 12)\n        energy[:, j] = chord * blend + noise\n\n# Smooth transitions between chords\nfor i in range(1, n_frames):\n    energy[:, i] = 0.7 * energy[:, i] + 0.3 * energy[:, i - 1]\n\n# Normalize to 0-1\nenergy = energy / energy.max()\n\n# Build long-form dataframe with bin edges for proper rect rendering\nrows = []\nfor t_idx, t_val in enumerate(time_seconds):\n    for p_idx, pitch in enumerate(pitch_classes):\n        rows.append(\n            {\n                \"t1\": round(t_val, 3),\n                \"t2\": round(t_val + frame_width, 3),\n                \"Pitch Class\": pitch,\n                \"Energy\": round(energy[p_idx, t_idx], 3),\n            }\n        )\n\ndf = pd.DataFrame(rows)\n\n# Plot\ntitle_text = \"heatmap-chromagram · python · altair · anyplot.ai\"\n\nheatmap = (\n    alt.Chart(df)\n    .mark_rect()\n    .encode(\n        x=alt.X(\n            \"t1:Q\",\n            title=\"Time (seconds)\",\n            axis=alt.Axis(labelFontSize=12, titleFontSize=13, titlePadding=10, values=list(range(0, 25, 2))),\n            scale=alt.Scale(domain=[0, 24.2]),\n        ),\n        x2=\"t2:Q\",\n        y=alt.Y(\n            \"Pitch Class:N\",\n            title=\"Pitch Class\",\n            sort=pitch_classes,\n            axis=alt.Axis(labelFontSize=12, titleFontSize=13, titlePadding=10),\n        ),\n        color=alt.Color(\n            \"Energy:Q\",\n            scale=alt.Scale(range=IMPRINT_SEQ),\n            legend=alt.Legend(\n                title=\"Energy\",\n                titleFontSize=12,\n                labelFontSize=11,\n                gradientLength=280,\n                gradientThickness=14,\n                titlePadding=6,\n                offset=10,\n                direction=\"vertical\",\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"Pitch Class:N\"),\n            alt.Tooltip(\"t1:Q\", title=\"Time (s)\", format=\".1f\"),\n            alt.Tooltip(\"Energy:Q\", format=\".3f\"),\n        ],\n    )\n)\n\nchart = (\n    heatmap.properties(\n        width=420,\n        height=440,\n        background=PAGE_BG,\n        title=alt.Title(\n            title_text,\n            subtitle=\"Pitch class energy · C → G → Am → F chord progression\",\n            fontSize=16,\n            subtitleFontSize=12,\n            color=INK,\n            subtitleColor=INK_MUTED,\n            anchor=\"start\",\n            offset=16,\n        ),\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=420, continuousHeight=440)\n    .configure_axis(domainColor=INK_SOFT, tickColor=INK_SOFT, grid=False, labelColor=INK_SOFT, titleColor=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG then pad to exact canonical target (2400 × 2400 — square for heatmaps)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\nTW, TH = 2400, 2400\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        f\"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\nchart.save(f\"plot-{THEME}.html\")\n"}