Music Chromagram (Pitch Class Distribution over Time) — Plotly

A heatmap-style visualization showing the energy distribution across the 12 pitch classes (C, C#, D, D#, E, F, F#, G, G#, A, A#, B) over time. Each column represents a time frame and each row a pitch class, with color intensity indicating the energy or magnitude at that pitch-time point. Widely used in music information retrieval to analyze harmonic content, detect chords, estimate musical key, and study tonal progressions in audio signals.

Music Chromagram (Pitch Class Distribution over Time) rendered with Plotly

Python source (Plotly)

""" anyplot.ai
heatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)
Library: plotly 6.8.0 | Python 3.13.14
Quality: 86/100 | Updated: 2026-06-24
"""

import os

import numpy as np
import plotly.graph_objects as go


# Theme — Imprint palette chrome tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Imprint sequential colorscale for single-polarity energy data
imprint_seq = [[0.0, "#009E73"], [1.0, "#4467A3"]]

# Data
np.random.seed(42)

pitch_classes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
n_frames = 120
frame_duration = 0.05
time_frames = np.arange(n_frames) * frame_duration

# Chord profiles (energy distribution across 12 pitch classes)
c_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])
g_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])
a_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])
f_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])

# Build chromagram with chord progression: C → G → Am → F
chord_names = ["C maj", "G maj", "A min", "F maj"]
chords = [c_major, g_major, a_minor, f_major]
segment_length = n_frames // len(chords)

energy = np.zeros((12, n_frames))
for i, chord in enumerate(chords):
    start = i * segment_length
    end = start + segment_length if i < len(chords) - 1 else n_frames
    for t in range(start, end):
        noise = np.random.normal(0, 0.06, 12)
        energy[:, t] = np.clip(chord + noise, 0, 1.2)

# Smooth transitions between chords
for i in range(1, len(chords)):
    blend_width = 4
    for offset in range(-blend_width, blend_width):
        t = i * segment_length + offset
        if 0 <= t < n_frames:
            alpha = (offset + blend_width) / (2 * blend_width)
            blended = (1 - alpha) * chords[i - 1] + alpha * chords[i]
            noise = np.random.normal(0, 0.04, 12)
            energy[:, t] = np.clip(blended + noise, 0, 1.2)

# Plot
fig = go.Figure(
    data=go.Heatmap(
        z=energy,
        x=np.round(time_frames, 2),
        y=pitch_classes,
        colorscale=imprint_seq,
        zmin=0,
        zmax=1.2,
        colorbar={
            "title": {"text": "Energy", "font": {"size": 12, "color": INK}},
            "tickfont": {"size": 10, "color": INK_SOFT},
            "thickness": 15,
            "len": 0.8,
            "outlinewidth": 0,
            "tickvals": [0, 0.3, 0.6, 0.9, 1.2],
            "bgcolor": ELEVATED_BG,
        },
        hoverongaps=False,
        hovertemplate="<b>%{y}</b> at %{x}s<br>Energy: %{z:.3f}<extra></extra>",
        xgap=0.5,
        ygap=1,
    )
)

# Chord section annotations and separators
# White-tinted separators stay visible against the colored heatmap cells in both themes
sep_color = "rgba(255,255,255,0.65)"
for i in range(len(chords)):
    start_time = i * segment_length * frame_duration
    end_time = (i + 1) * segment_length * frame_duration if i < len(chords) - 1 else n_frames * frame_duration
    mid_time = (start_time + end_time) / 2

    fig.add_annotation(
        x=mid_time,
        y=1.12,
        yref="paper",
        text=f"<b>{chord_names[i]}</b>",
        showarrow=False,
        font={"size": 13, "color": INK},
    )

    if i > 0:
        fig.add_shape(
            type="line",
            x0=i * segment_length * frame_duration,
            x1=i * segment_length * frame_duration,
            y0=-0.5,
            y1=11.5,
            line={"color": sep_color, "width": 2, "dash": "dot"},
        )

# Bracket line connecting chord labels
fig.add_shape(
    type="line",
    x0=0,
    x1=n_frames * frame_duration - frame_duration,
    y0=1.04,
    y1=1.04,
    yref="paper",
    line={"color": INK_SOFT, "width": 1.5},
)

# Layout
fig.update_layout(
    autosize=False,
    title={
        "text": "heatmap-chromagram · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.97,
    },
    xaxis={
        "title": {"text": "Time (seconds)", "font": {"size": 12, "color": INK}, "standoff": 10},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "showgrid": False,
        "dtick": 0.5,
        "zeroline": False,
        "showline": True,
        "linecolor": INK_SOFT,
        "linewidth": 1,
        "ticks": "outside",
        "tickcolor": INK_SOFT,
        "ticklen": 5,
    },
    yaxis={
        "title": {"text": "Pitch Class", "font": {"size": 12, "color": INK}, "standoff": 8},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "showgrid": False,
        "categoryorder": "array",
        "categoryarray": pitch_classes,
        "zeroline": False,
        "showline": True,
        "linecolor": INK_SOFT,
        "linewidth": 1,
        "ticks": "outside",
        "tickcolor": INK_SOFT,
        "ticklen": 5,
    },
    template="plotly_white",
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    margin={"l": 80, "r": 50, "t": 100, "b": 60},
)

# Save
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Music Chromagram (Pitch Class Distribution over Time) on anyplot.ai.

Other implementations