Music Chromagram (Pitch Class Distribution over Time) — ggplot2

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 ggplot2

Renders

R source (ggplot2)

#' anyplot.ai
#' heatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time)
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 91/100 | Created: 2026-06-24

library(ggplot2)
library(ragg)

set.seed(42)

# Theme tokens
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"

# Data: synthetic chroma features for a I-vi-IV-V chord progression in C major
n_frames    <- 80
time_sec    <- seq(0, 8, length.out = n_frames)
pitch_names <- c("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")

# Chord pitch-class indices (1-based): C=1, C#=2, D=3, ..., B=12
chords <- list(
    c(1, 5, 8),   # C major: C, E, G
    c(10, 1, 5),  # A minor: A, C, E
    c(6, 10, 1),  # F major: F, A, C
    c(8, 12, 3)   # G major: G, B, D
)
chord_seq <- c(rep(1, 20), rep(2, 20), rep(3, 20), rep(4, 20))

# Build 12 x n_frames energy matrix: background noise + chord energy peaks
energy_mat <- matrix(pmax(0, rnorm(12 * n_frames, mean = 0.05, sd = 0.04)), nrow = 12)
for (f in seq_len(n_frames)) {
    active <- chords[[chord_seq[f]]]
    energy_mat[active, f] <- pmin(1, pmax(0, rnorm(length(active), mean = 0.78, sd = 0.07)))
}

# Long-format data frame using integer frame indices for clean tile placement
df <- data.frame(
    frame       = rep(seq_len(n_frames), each = 12),
    pitch_class = factor(rep(pitch_names, times = n_frames), levels = pitch_names),
    energy      = as.vector(energy_mat)
)

# Primary x-axis: time labels at 2-second intervals
label_frames <- c(1, 21, 41, 61, 80)
label_times  <- round(time_sec[label_frames])

# Secondary x-axis: chord names at midpoints of each 20-frame region
chord_midpoints <- c(10.5, 30.5, 50.5, 70.5)
chord_labels    <- c("C", "Am", "F", "G")

# Plot
plot_title <- "heatmap-chromagram · r · ggplot2 · anyplot.ai"

p <- ggplot(df, aes(x = frame, y = pitch_class, fill = energy)) +
    geom_tile() +
    # Thin dashed separators at chord-change boundaries
    geom_vline(
        xintercept = c(20.5, 40.5, 60.5),
        color = INK_SOFT, linewidth = 0.4, linetype = "dashed"
    ) +
    scale_fill_gradient(
        low    = "#009E73",
        high   = "#4467A3",
        name   = "Energy",
        limits = c(0, 1),
        breaks = c(0, 0.25, 0.5, 0.75, 1.0)
    ) +
    # Primary axis: seconds; secondary axis: chord name per region
    scale_x_continuous(
        breaks = label_frames,
        labels = paste0(label_times, "s"),
        expand = c(0, 0),
        sec.axis = sec_axis(
            ~ .,
            breaks = chord_midpoints,
            labels = chord_labels,
            name   = "Chord"
        )
    ) +
    scale_y_discrete(expand = c(0, 0)) +
    # coord_fixed: lock panel to square so the plot fills the square canvas
    coord_fixed(ratio = n_frames / 12) +
    labs(
        title = plot_title,
        x     = "Time (seconds)",
        y     = "Pitch Class"
    ) +
    theme_minimal(base_size = 8) +
    theme(
        plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),
        panel.background  = element_rect(fill = PAGE_BG, color = NA),
        panel.grid.major  = element_blank(),
        panel.grid.minor  = element_blank(),
        panel.border      = element_rect(color = INK_SOFT, fill = NA, linewidth = 0.5),
        axis.title        = element_text(color = INK,      size = 10),
        axis.text         = element_text(color = INK_SOFT, size = 8),
        axis.text.x.top   = element_text(color = INK,      size = 9, face = "bold"),
        axis.title.x.top  = element_text(color = INK_SOFT, size = 9),
        axis.ticks        = element_line(color = INK_SOFT, linewidth = 0.3),
        plot.title        = element_text(color = INK, size = 12),
        plot.margin       = margin(12, 12, 12, 12, "pt"),
        legend.background = element_rect(fill = ELEVATED_BG, color = NA),
        legend.text       = element_text(color = INK_SOFT, size = 8),
        legend.title      = element_text(color = INK, size = 10),
        legend.position   = "right",
        legend.key.height = unit(1.5, "in"),
        legend.key.width  = unit(0.25, "in")
    )

# Save — square canvas: 6 x 6 in at 400 dpi = 2400 x 2400 px
ggsave(
    filename = sprintf("plot-%s.png", THEME),
    plot     = p,
    device   = ragg::agg_png,
    width    = 6,
    height   = 6,
    units    = "in",
    dpi      = 400
)

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

Other implementations