{"spec_id":"heatmap-chromagram","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nheatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)\nLibrary: plotly 6.8.0 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme — Imprint palette chrome 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\"\n\n# Imprint sequential colorscale for single-polarity energy data\nimprint_seq = [[0.0, \"#009E73\"], [1.0, \"#4467A3\"]]\n\n# Data\nnp.random.seed(42)\n\npitch_classes = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"]\nn_frames = 120\nframe_duration = 0.05\ntime_frames = np.arange(n_frames) * frame_duration\n\n# Chord profiles (energy distribution across 12 pitch classes)\nc_major = np.array([1.0, 0.05, 0.1, 0.05, 0.8, 0.1, 0.05, 0.7, 0.05, 0.1, 0.05, 0.1])\ng_major = np.array([0.1, 0.05, 0.7, 0.05, 0.1, 0.05, 0.05, 1.0, 0.05, 0.1, 0.05, 0.8])\na_minor = np.array([0.7, 0.05, 0.1, 0.05, 0.8, 0.1, 0.05, 0.1, 0.05, 1.0, 0.05, 0.1])\nf_major = np.array([0.8, 0.05, 0.1, 0.05, 0.1, 1.0, 0.05, 0.1, 0.05, 0.7, 0.05, 0.1])\n\n# Build chromagram with chord progression: C → G → Am → F\nchord_names = [\"C maj\", \"G maj\", \"A min\", \"F maj\"]\nchords = [c_major, g_major, a_minor, f_major]\nsegment_length = n_frames // len(chords)\n\nenergy = np.zeros((12, n_frames))\nfor i, chord in enumerate(chords):\n    start = i * segment_length\n    end = start + segment_length if i < len(chords) - 1 else n_frames\n    for t in range(start, end):\n        noise = np.random.normal(0, 0.06, 12)\n        energy[:, t] = np.clip(chord + noise, 0, 1.2)\n\n# Smooth transitions between chords\nfor i in range(1, len(chords)):\n    blend_width = 4\n    for offset in range(-blend_width, blend_width):\n        t = i * segment_length + offset\n        if 0 <= t < n_frames:\n            alpha = (offset + blend_width) / (2 * blend_width)\n            blended = (1 - alpha) * chords[i - 1] + alpha * chords[i]\n            noise = np.random.normal(0, 0.04, 12)\n            energy[:, t] = np.clip(blended + noise, 0, 1.2)\n\n# Plot\nfig = go.Figure(\n    data=go.Heatmap(\n        z=energy,\n        x=np.round(time_frames, 2),\n        y=pitch_classes,\n        colorscale=imprint_seq,\n        zmin=0,\n        zmax=1.2,\n        colorbar={\n            \"title\": {\"text\": \"Energy\", \"font\": {\"size\": 12, \"color\": INK}},\n            \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n            \"thickness\": 15,\n            \"len\": 0.8,\n            \"outlinewidth\": 0,\n            \"tickvals\": [0, 0.3, 0.6, 0.9, 1.2],\n            \"bgcolor\": ELEVATED_BG,\n        },\n        hoverongaps=False,\n        hovertemplate=\"<b>%{y}</b> at %{x}s<br>Energy: %{z:.3f}<extra></extra>\",\n        xgap=0.5,\n        ygap=1,\n    )\n)\n\n# Chord section annotations and separators\n# White-tinted separators stay visible against the colored heatmap cells in both themes\nsep_color = \"rgba(255,255,255,0.65)\"\nfor i in range(len(chords)):\n    start_time = i * segment_length * frame_duration\n    end_time = (i + 1) * segment_length * frame_duration if i < len(chords) - 1 else n_frames * frame_duration\n    mid_time = (start_time + end_time) / 2\n\n    fig.add_annotation(\n        x=mid_time,\n        y=1.12,\n        yref=\"paper\",\n        text=f\"<b>{chord_names[i]}</b>\",\n        showarrow=False,\n        font={\"size\": 13, \"color\": INK},\n    )\n\n    if i > 0:\n        fig.add_shape(\n            type=\"line\",\n            x0=i * segment_length * frame_duration,\n            x1=i * segment_length * frame_duration,\n            y0=-0.5,\n            y1=11.5,\n            line={\"color\": sep_color, \"width\": 2, \"dash\": \"dot\"},\n        )\n\n# Bracket line connecting chord labels\nfig.add_shape(\n    type=\"line\",\n    x0=0,\n    x1=n_frames * frame_duration - frame_duration,\n    y0=1.04,\n    y1=1.04,\n    yref=\"paper\",\n    line={\"color\": INK_SOFT, \"width\": 1.5},\n)\n\n# Layout\nfig.update_layout(\n    autosize=False,\n    title={\n        \"text\": \"heatmap-chromagram · python · plotly · anyplot.ai\",\n        \"font\": {\"size\": 16, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n        \"y\": 0.97,\n    },\n    xaxis={\n        \"title\": {\"text\": \"Time (seconds)\", \"font\": {\"size\": 12, \"color\": INK}, \"standoff\": 10},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"showgrid\": False,\n        \"dtick\": 0.5,\n        \"zeroline\": False,\n        \"showline\": True,\n        \"linecolor\": INK_SOFT,\n        \"linewidth\": 1,\n        \"ticks\": \"outside\",\n        \"tickcolor\": INK_SOFT,\n        \"ticklen\": 5,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Pitch Class\", \"font\": {\"size\": 12, \"color\": INK}, \"standoff\": 8},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"showgrid\": False,\n        \"categoryorder\": \"array\",\n        \"categoryarray\": pitch_classes,\n        \"zeroline\": False,\n        \"showline\": True,\n        \"linecolor\": INK_SOFT,\n        \"linewidth\": 1,\n        \"ticks\": \"outside\",\n        \"tickcolor\": INK_SOFT,\n        \"ticklen\": 5,\n    },\n    template=\"plotly_white\",\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    margin={\"l\": 80, \"r\": 50, \"t\": 100, \"b\": 60},\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}