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.

#' 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.