Music Chromagram (Pitch Class Distribution over Time) — Bokeh

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 Bokeh

Python source (Bokeh)

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

import os
import sys
import time
from pathlib import Path


# Remove this script's directory from sys.path so 'bokeh.py' doesn't shadow the installed package
_here = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _here]

import numpy as np
import pandas as pd
from bokeh.io import output_file, save
from bokeh.models import (
    BasicTicker,
    ColorBar,
    ColumnDataSource,
    FixedTicker,
    HoverTool,
    Label,
    LinearColorMapper,
    Range1d,
    Span,
)
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"


# Imprint sequential colormap: brand green (#009E73) → blue (#4467A3), single-polarity energy
IMPRINT_SEQ = [
    "#{:02X}{:02X}{:02X}".format(round(t * 68 / 255), round(158 - t * 55 / 255), round(115 + t * 48 / 255))
    for t in range(256)
]

# Data — simulated chromagram: 12 pitch classes over 80 time frames
np.random.seed(42)
pitch_classes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
n_pitch = len(pitch_classes)
n_frames = 80
time_seconds = np.linspace(0, 8, n_frames)

# Build energy matrix simulating chord progressions:
# C major (C-E-G) -> G major (G-B-D) -> Am (A-C-E) -> F major (F-A-C)
energy = np.random.uniform(0.02, 0.12, size=(12, n_frames))

chord_patterns = {
    "C_major": {"notes": [0, 4, 7], "boost": [0.9, 0.7, 0.8]},
    "G_major": {"notes": [7, 11, 2], "boost": [0.9, 0.7, 0.75]},
    "A_minor": {"notes": [9, 0, 4], "boost": [0.85, 0.7, 0.7]},
    "F_major": {"notes": [5, 9, 0], "boost": [0.9, 0.7, 0.75]},
}

chord_sequence = ["C_major", "G_major", "A_minor", "F_major"]
chord_display = ["C major", "G major", "A minor", "F major"]
frames_per_chord = n_frames // len(chord_sequence)

for idx, chord_name in enumerate(chord_sequence):
    start = idx * frames_per_chord
    end = start + frames_per_chord
    pattern = chord_patterns[chord_name]
    for note_idx, boost in zip(pattern["notes"], pattern["boost"], strict=True):
        energy[note_idx, start:end] += boost + np.random.uniform(-0.08, 0.08, end - start)
    for note_idx in pattern["notes"]:
        neighbor = (note_idx + 7) % 12
        energy[neighbor, start:end] += 0.15 + np.random.uniform(-0.03, 0.03, end - start)

# Smooth transitions between chords
for i in range(1, len(chord_sequence)):
    boundary = i * frames_per_chord
    if boundary - 2 >= 0 and boundary + 2 < n_frames:
        for row in range(12):
            window = energy[row, boundary - 2 : boundary + 3]
            energy[row, boundary - 2 : boundary + 3] = np.convolve(window, [0.15, 0.25, 0.3, 0.2, 0.1], mode="same")

energy = np.clip(energy, 0, 1)

dt = time_seconds[1] - time_seconds[0]

# image glyph renders row 0 at bottom; reversing places C at top
energy_image = energy[::-1, :]

# Flatten to DataFrame for HoverTool interactivity
records = []
for i, pitch in enumerate(pitch_classes):
    y_pos = n_pitch - 1 - i
    for j in range(n_frames):
        records.append(
            {"time": float(time_seconds[j]), "pitch": pitch, "y": y_pos, "energy": round(float(energy[i, j]), 3)}
        )

source = ColumnDataSource(pd.DataFrame(records))

mapper = LinearColorMapper(palette=IMPRINT_SEQ, low=0, high=1)

# Square canvas (2400×2400) — symmetric pitch×time grid suits 1:1 format
p = figure(
    width=2400,
    height=2400,
    y_range=Range1d(-0.5, n_pitch - 0.5),
    x_range=(-dt / 2, 8 + dt / 2),
    title="heatmap-chromagram · python · bokeh · anyplot.ai",
    x_axis_label="Time (seconds)",
    y_axis_label="Pitch Class",
    toolbar_location=None,
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=110,
    min_border_right=230,
)

# Y-axis: pitch class labels
p.yaxis.ticker = FixedTicker(ticks=list(range(n_pitch)))
p.yaxis.major_label_overrides = {i: pitch_classes[n_pitch - 1 - i] for i in range(n_pitch)}

# Seamless heatmap via image glyph — no cell gaps
p.image(image=[energy_image], x=-dt / 2, y=-0.5, dw=8 + dt, dh=n_pitch, color_mapper=mapper)

# Invisible rect overlay for HoverTool interactivity
r = p.rect(x="time", y="y", width=dt, height=1, source=source, fill_alpha=0, line_alpha=0)

# Chord boundary Span annotations
for t_boundary in [2.0, 4.0, 6.0]:
    p.add_layout(
        Span(
            location=t_boundary,
            dimension="height",
            line_color=INK_SOFT,
            line_width=3,
            line_alpha=0.55,
            line_dash="dashed",
        )
    )

# Chord label annotations at the top of each region
for idx, label_text in enumerate(chord_display):
    t_center = idx * 2.0 + 1.0
    p.add_layout(
        Label(
            x=t_center,
            y=n_pitch - 0.6,
            text=label_text,
            text_font_size="28pt",
            text_color=INK_SOFT,
            text_align="center",
            text_baseline="top",
            background_fill_color=PAGE_BG,
            background_fill_alpha=0.65,
        )
    )

# Color bar
color_bar = ColorBar(
    color_mapper=mapper,
    width=60,
    ticker=BasicTicker(desired_num_ticks=6),
    label_standoff=15,
    major_label_text_font_size="34pt",
    major_label_text_color=INK_SOFT,
    border_line_color=None,
    padding=20,
    title="Energy",
    title_text_font_size="36pt",
    title_text_color=INK,
    title_standoff=20,
    background_fill_color=PAGE_BG,
    bar_line_color=None,
    major_tick_line_color=INK_SOFT,
    minor_tick_line_color=None,
)
p.add_layout(color_bar, "right")

hover = HoverTool(
    tooltips=[("Pitch", "@pitch"), ("Time", "@time{0.00} s"), ("Energy", "@energy{0.000}")], renderers=[r]
)
p.add_tools(hover)

# Font sizes — canonical bokeh values for 2400×2400 canvas
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT

# No grid — heatmap fills the entire plot area
p.xgrid.grid_line_color = None
p.ygrid.grid_line_color = None
p.outline_line_color = INK_SOFT

# Theme-adaptive background
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG

# Save interactive HTML (required Bokeh catalog artifact)
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome — avoid export_png (snap chromium incompatibility)
W, H = 2400, 2400
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
# CDP override ensures exact viewport — window-size alone can drift due to browser chrome
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

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

Other implementations