|
| 1 | +""" pyplots.ai |
| 2 | +heatmap-chromagram: Music Chromagram (Pitch Class Distribution over Time) |
| 3 | +Library: pygal 3.1.0 | Python 3.14.3 |
| 4 | +Quality: 91/100 | Updated: 2026-03-23 |
| 5 | +""" |
| 6 | + |
| 7 | +import sys |
| 8 | + |
| 9 | +import numpy as np |
| 10 | + |
| 11 | + |
| 12 | +# Temporarily remove current directory from path to avoid name collision |
| 13 | +_cwd = sys.path[0] if sys.path[0] else "." |
| 14 | +if _cwd in sys.path: |
| 15 | + sys.path.remove(_cwd) |
| 16 | + |
| 17 | +from pygal.graph.graph import Graph # noqa: E402 |
| 18 | +from pygal.style import Style # noqa: E402 |
| 19 | + |
| 20 | + |
| 21 | +# Restore path |
| 22 | +sys.path.insert(0, _cwd) |
| 23 | + |
| 24 | + |
| 25 | +class ChromagramHeatmap(Graph): |
| 26 | + """Custom chromagram heatmap extending pygal's Graph base class.""" |
| 27 | + |
| 28 | + _series_margin = 0 |
| 29 | + |
| 30 | + def __init__(self, *args, **kwargs): |
| 31 | + self.chroma_data = kwargs.pop("chroma_data", []) |
| 32 | + self.pitch_labels = kwargs.pop("pitch_labels", []) |
| 33 | + self.time_labels = kwargs.pop("time_labels", []) |
| 34 | + self.colormap = kwargs.pop("colormap", []) |
| 35 | + self.x_axis_title = kwargs.pop("x_axis_title", "") |
| 36 | + self.y_axis_title = kwargs.pop("y_axis_title", "") |
| 37 | + self.chord_regions = kwargs.pop("chord_regions", []) |
| 38 | + self.vmin = kwargs.pop("vmin", 0.0) |
| 39 | + self.vmax = kwargs.pop("vmax", 1.0) |
| 40 | + super().__init__(*args, **kwargs) |
| 41 | + |
| 42 | + def _interpolate_color(self, value): |
| 43 | + """Interpolate color from sequential colormap based on value.""" |
| 44 | + normalized = max(0, min(1, (value - self.vmin) / (self.vmax - self.vmin))) |
| 45 | + pos = normalized * (len(self.colormap) - 1) |
| 46 | + idx1 = int(pos) |
| 47 | + idx2 = min(idx1 + 1, len(self.colormap) - 1) |
| 48 | + frac = pos - idx1 |
| 49 | + |
| 50 | + c1, c2 = self.colormap[idx1], self.colormap[idx2] |
| 51 | + r = int(int(c1[1:3], 16) + (int(c2[1:3], 16) - int(c1[1:3], 16)) * frac) |
| 52 | + g = int(int(c1[3:5], 16) + (int(c2[3:5], 16) - int(c1[3:5], 16)) * frac) |
| 53 | + b = int(int(c1[5:7], 16) + (int(c2[5:7], 16) - int(c1[5:7], 16)) * frac) |
| 54 | + return f"#{r:02x}{g:02x}{b:02x}" |
| 55 | + |
| 56 | + def _plot(self): |
| 57 | + """Draw the chromagram heatmap with chord region annotations.""" |
| 58 | + if not self.chroma_data: |
| 59 | + return |
| 60 | + |
| 61 | + n_rows = len(self.chroma_data) |
| 62 | + n_cols = len(self.chroma_data[0]) |
| 63 | + |
| 64 | + plot_width = self.view.width |
| 65 | + plot_height = self.view.height |
| 66 | + |
| 67 | + # Tighter margins for better canvas utilization |
| 68 | + margin_left = 245 |
| 69 | + margin_bottom = 180 |
| 70 | + margin_top = 80 |
| 71 | + margin_right = 300 |
| 72 | + |
| 73 | + avail_w = plot_width - margin_left - margin_right |
| 74 | + avail_h = plot_height - margin_bottom - margin_top |
| 75 | + |
| 76 | + cell_w = avail_w / n_cols |
| 77 | + cell_h = avail_h / n_rows |
| 78 | + grid_w = n_cols * cell_w |
| 79 | + grid_h = n_rows * cell_h |
| 80 | + |
| 81 | + x0 = self.view.x(0) + margin_left |
| 82 | + y0 = self.view.y(n_rows) + margin_top |
| 83 | + |
| 84 | + plot_node = self.nodes["plot"] |
| 85 | + hmap = self.svg.node(plot_node, class_="chromagram-heatmap") |
| 86 | + |
| 87 | + # --- Chord region labels and separators (Data Storytelling) --- |
| 88 | + region_font = 38 |
| 89 | + region_colors = ["#e8e8e8", "#f4f4f4", "#e8e8e8", "#f4f4f4"] |
| 90 | + for idx, region in enumerate(self.chord_regions): |
| 91 | + rx = x0 + region["start"] * cell_w |
| 92 | + rw = (region["end"] - region["start"]) * cell_w |
| 93 | + |
| 94 | + # Subtle alternating background band behind heatmap |
| 95 | + self.svg.node( |
| 96 | + hmap, |
| 97 | + "rect", |
| 98 | + x=rx, |
| 99 | + y=y0, |
| 100 | + width=rw, |
| 101 | + height=grid_h, |
| 102 | + fill=region_colors[idx % len(region_colors)], |
| 103 | + opacity="0.15", |
| 104 | + ) |
| 105 | + |
| 106 | + # Chord label above the heatmap |
| 107 | + label_x = rx + rw / 2 |
| 108 | + label_y = y0 - 20 |
| 109 | + t = self.svg.node(hmap, "text", x=label_x, y=label_y) |
| 110 | + t.set("text-anchor", "middle") |
| 111 | + t.set("fill", "#555555") |
| 112 | + t.set("style", f"font-size:{region_font}px;font-weight:bold;font-family:sans-serif;font-style:italic") |
| 113 | + t.text = region["label"] |
| 114 | + |
| 115 | + # Vertical separator line at chord boundary (except first) |
| 116 | + if region["start"] > 0: |
| 117 | + sep_x = rx |
| 118 | + self.svg.node( |
| 119 | + hmap, "line", x1=sep_x, y1=y0 - 5, x2=sep_x, y2=y0 + grid_h + 5, stroke="#999999", fill="none" |
| 120 | + ) |
| 121 | + self.svg.node(hmap, "line", x1=sep_x, y1=y0 - 5, x2=sep_x, y2=y0 + grid_h + 5).set( |
| 122 | + "style", "stroke:#999999;stroke-width:2;stroke-dasharray:8,6" |
| 123 | + ) |
| 124 | + |
| 125 | + # --- Subtitle: harmonic progression pattern --- |
| 126 | + subtitle_x = x0 + grid_w / 2 |
| 127 | + subtitle_y = y0 + grid_h + 165 |
| 128 | + st = self.svg.node(hmap, "text", x=subtitle_x, y=subtitle_y) |
| 129 | + st.set("text-anchor", "middle") |
| 130 | + st.set("fill", "#777777") |
| 131 | + st.set("style", "font-size:34px;font-family:sans-serif;font-style:italic") |
| 132 | + st.text = "Chord progression: I \u2013 V \u2013 vi \u2013 IV (C major key)" |
| 133 | + |
| 134 | + # --- Heatmap cells --- |
| 135 | + for i in range(n_rows): |
| 136 | + row_group = self.svg.node(hmap, "g", class_=f"pitch-row-{i}") |
| 137 | + for j in range(n_cols): |
| 138 | + value = self.chroma_data[i][j] |
| 139 | + color = self._interpolate_color(value) |
| 140 | + x = x0 + j * cell_w |
| 141 | + y = y0 + i * cell_h |
| 142 | + |
| 143 | + rect = self.svg.node( |
| 144 | + row_group, "rect", x=x, y=y, width=cell_w + 0.5, height=cell_h + 0.5, fill=color, stroke="none" |
| 145 | + ) |
| 146 | + # Native pygal tooltip via <title> element |
| 147 | + title_el = self.svg.node(rect, "title") |
| 148 | + title_el.text = f"{self.pitch_labels[i]} @ {self.time_labels[j]}s — Energy: {value:.3f}" |
| 149 | + |
| 150 | + # --- Thin horizontal separators between pitch rows --- |
| 151 | + for i in range(1, n_rows): |
| 152 | + sep_y = y0 + i * cell_h |
| 153 | + self.svg.node( |
| 154 | + hmap, "line", x1=x0, y1=sep_y, x2=x0 + grid_w, y2=sep_y, stroke="#ffffff", fill="none", opacity="0.3" |
| 155 | + ).set("style", "stroke-width:1.5") |
| 156 | + |
| 157 | + # --- Heatmap border --- |
| 158 | + self.svg.node(hmap, "rect", x=x0, y=y0, width=grid_w, height=grid_h, fill="none", stroke="#333333") |
| 159 | + |
| 160 | + # --- Y-axis: pitch class labels --- |
| 161 | + row_font = min(44, int(cell_h * 0.6)) |
| 162 | + for i, label in enumerate(self.pitch_labels): |
| 163 | + y = y0 + i * cell_h + cell_h / 2 |
| 164 | + t = self.svg.node(hmap, "text", x=x0 - 18, y=y + row_font * 0.35) |
| 165 | + t.set("text-anchor", "end") |
| 166 | + t.set("fill", "#333333") |
| 167 | + t.set("style", f"font-size:{row_font}px;font-weight:600;font-family:sans-serif") |
| 168 | + t.text = label |
| 169 | + |
| 170 | + # --- Y-axis title (rotated) --- |
| 171 | + if self.y_axis_title: |
| 172 | + yt_x = x0 - 200 |
| 173 | + yt_y = y0 + grid_h / 2 |
| 174 | + t = self.svg.node(hmap, "text", x=yt_x, y=yt_y) |
| 175 | + t.set("text-anchor", "middle") |
| 176 | + t.set("fill", "#333333") |
| 177 | + t.set("style", "font-size:48px;font-weight:bold;font-family:sans-serif") |
| 178 | + t.set("transform", f"rotate(-90, {yt_x}, {yt_y})") |
| 179 | + t.text = self.y_axis_title |
| 180 | + |
| 181 | + # --- X-axis: time labels --- |
| 182 | + col_font = 36 |
| 183 | + step = max(1, n_cols // 10) |
| 184 | + for j in range(0, n_cols, step): |
| 185 | + x = x0 + j * cell_w + cell_w / 2 |
| 186 | + y = y0 + grid_h + col_font + 15 |
| 187 | + t = self.svg.node(hmap, "text", x=x, y=y) |
| 188 | + t.set("text-anchor", "middle") |
| 189 | + t.set("fill", "#333333") |
| 190 | + t.set("style", f"font-size:{col_font}px;font-family:sans-serif") |
| 191 | + t.text = self.time_labels[j] |
| 192 | + |
| 193 | + # --- X-axis title --- |
| 194 | + if self.x_axis_title: |
| 195 | + xt_x = x0 + grid_w / 2 |
| 196 | + xt_y = y0 + grid_h + 130 |
| 197 | + t = self.svg.node(hmap, "text", x=xt_x, y=xt_y) |
| 198 | + t.set("text-anchor", "middle") |
| 199 | + t.set("fill", "#333333") |
| 200 | + t.set("style", "font-size:48px;font-weight:bold;font-family:sans-serif") |
| 201 | + t.text = self.x_axis_title |
| 202 | + |
| 203 | + # --- Colorbar --- |
| 204 | + cb_w = 50 |
| 205 | + cb_h = grid_h * 0.85 |
| 206 | + cb_x = x0 + grid_w + 50 |
| 207 | + cb_y = y0 + (grid_h - cb_h) / 2 |
| 208 | + |
| 209 | + n_seg = 60 |
| 210 | + seg_h = cb_h / n_seg |
| 211 | + for s in range(n_seg): |
| 212 | + sv = self.vmax - (self.vmax - self.vmin) * s / (n_seg - 1) |
| 213 | + self.svg.node( |
| 214 | + hmap, "rect", x=cb_x, y=cb_y + s * seg_h, width=cb_w, height=seg_h + 1, fill=self._interpolate_color(sv) |
| 215 | + ) |
| 216 | + |
| 217 | + # Colorbar border |
| 218 | + self.svg.node(hmap, "rect", x=cb_x, y=cb_y, width=cb_w, height=cb_h, fill="none", stroke="#333333") |
| 219 | + |
| 220 | + # Colorbar tick labels |
| 221 | + cb_font = 36 |
| 222 | + for frac, txt in [ |
| 223 | + (0.0, f"{self.vmax:.1f}"), |
| 224 | + (0.5, f"{(self.vmax + self.vmin) / 2:.1f}"), |
| 225 | + (1.0, f"{self.vmin:.1f}"), |
| 226 | + ]: |
| 227 | + ty = cb_y + frac * cb_h + cb_font * 0.35 |
| 228 | + t = self.svg.node(hmap, "text", x=cb_x + cb_w + 15, y=ty) |
| 229 | + t.set("fill", "#333333") |
| 230 | + t.set("style", f"font-size:{cb_font}px;font-family:sans-serif") |
| 231 | + t.text = txt |
| 232 | + |
| 233 | + # Colorbar title |
| 234 | + t = self.svg.node(hmap, "text", x=cb_x + cb_w / 2, y=cb_y - 25) |
| 235 | + t.set("text-anchor", "middle") |
| 236 | + t.set("fill", "#333333") |
| 237 | + t.set("style", "font-size:40px;font-weight:bold;font-family:sans-serif") |
| 238 | + t.text = "Energy" |
| 239 | + |
| 240 | + def _compute(self): |
| 241 | + """Compute bounding box for the graph view.""" |
| 242 | + n_cols = len(self.chroma_data[0]) if self.chroma_data else 1 |
| 243 | + n_rows = len(self.chroma_data) if self.chroma_data else 1 |
| 244 | + self._box.xmin = 0 |
| 245 | + self._box.xmax = n_cols |
| 246 | + self._box.ymin = 0 |
| 247 | + self._box.ymax = n_rows |
| 248 | + |
| 249 | + |
| 250 | +# --- Data: C major -> G major -> Am -> F major chord progression --- |
| 251 | +np.random.seed(42) |
| 252 | + |
| 253 | +pitch_classes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] |
| 254 | +n_pitches = len(pitch_classes) |
| 255 | +n_frames = 80 |
| 256 | +frame_duration = 0.1 |
| 257 | +time_positions = [f"{i * frame_duration:.1f}" for i in range(n_frames)] |
| 258 | + |
| 259 | +chroma = np.random.uniform(0.02, 0.12, (n_pitches, n_frames)) |
| 260 | + |
| 261 | +# C major (C, E, G) - frames 0-19 |
| 262 | +for f in range(0, 20): |
| 263 | + chroma[0, f] += np.random.uniform(0.7, 0.95) |
| 264 | + chroma[4, f] += np.random.uniform(0.5, 0.75) |
| 265 | + chroma[7, f] += np.random.uniform(0.55, 0.8) |
| 266 | + |
| 267 | +# G major (G, B, D) - frames 20-39 |
| 268 | +for f in range(20, 40): |
| 269 | + chroma[7, f] += np.random.uniform(0.7, 0.95) |
| 270 | + chroma[11, f] += np.random.uniform(0.5, 0.75) |
| 271 | + chroma[2, f] += np.random.uniform(0.55, 0.8) |
| 272 | + |
| 273 | +# A minor (A, C, E) - frames 40-59 |
| 274 | +for f in range(40, 60): |
| 275 | + chroma[9, f] += np.random.uniform(0.7, 0.95) |
| 276 | + chroma[0, f] += np.random.uniform(0.5, 0.75) |
| 277 | + chroma[4, f] += np.random.uniform(0.55, 0.8) |
| 278 | + |
| 279 | +# F major (F, A, C) - frames 60-79 |
| 280 | +for f in range(60, 80): |
| 281 | + chroma[5, f] += np.random.uniform(0.7, 0.95) |
| 282 | + chroma[9, f] += np.random.uniform(0.5, 0.75) |
| 283 | + chroma[0, f] += np.random.uniform(0.55, 0.8) |
| 284 | + |
| 285 | +# Smooth transitions |
| 286 | +for f in range(n_frames - 1): |
| 287 | + chroma[:, f + 1] = 0.3 * chroma[:, f] + 0.7 * chroma[:, f + 1] |
| 288 | + |
| 289 | +chroma = np.clip(chroma, 0, 1) |
| 290 | + |
| 291 | +# Reverse so C is at bottom, B at top |
| 292 | +chroma_display = chroma[::-1] |
| 293 | +pitch_labels_display = pitch_classes[::-1] |
| 294 | + |
| 295 | +# Chord region definitions for storytelling annotations |
| 296 | +chord_regions = [ |
| 297 | + {"start": 0, "end": 20, "label": "C major"}, |
| 298 | + {"start": 20, "end": 40, "label": "G major"}, |
| 299 | + {"start": 40, "end": 60, "label": "A minor"}, |
| 300 | + {"start": 60, "end": 80, "label": "F major"}, |
| 301 | +] |
| 302 | + |
| 303 | +# pygal Style configuration |
| 304 | +custom_style = Style( |
| 305 | + background="white", |
| 306 | + plot_background="white", |
| 307 | + foreground="#333333", |
| 308 | + foreground_strong="#333333", |
| 309 | + foreground_subtle="#999999", |
| 310 | + colors=("#306998",), |
| 311 | + title_font_size=64, |
| 312 | + legend_font_size=40, |
| 313 | + label_font_size=40, |
| 314 | + value_font_size=36, |
| 315 | + font_family="sans-serif", |
| 316 | +) |
| 317 | + |
| 318 | +# Inferno-inspired sequential colormap |
| 319 | +sequential_colormap = [ |
| 320 | + "#000004", |
| 321 | + "#1b0c41", |
| 322 | + "#4a0c6b", |
| 323 | + "#781c6d", |
| 324 | + "#a52c60", |
| 325 | + "#cf4446", |
| 326 | + "#ed6925", |
| 327 | + "#fb9b06", |
| 328 | + "#f7d13d", |
| 329 | + "#fcffa4", |
| 330 | +] |
| 331 | + |
| 332 | +# Create chart using pygal Graph extension pattern |
| 333 | +chart = ChromagramHeatmap( |
| 334 | + width=4800, |
| 335 | + height=2700, |
| 336 | + style=custom_style, |
| 337 | + title="heatmap-chromagram · pygal · pyplots.ai", |
| 338 | + chroma_data=chroma_display.tolist(), |
| 339 | + pitch_labels=pitch_labels_display, |
| 340 | + time_labels=time_positions, |
| 341 | + colormap=sequential_colormap, |
| 342 | + chord_regions=chord_regions, |
| 343 | + show_legend=False, |
| 344 | + margin=80, |
| 345 | + margin_top=160, |
| 346 | + margin_bottom=60, |
| 347 | + show_x_labels=False, |
| 348 | + show_y_labels=False, |
| 349 | + x_axis_title="Time (seconds)", |
| 350 | + y_axis_title="Pitch Class", |
| 351 | + vmin=0.0, |
| 352 | + vmax=1.0, |
| 353 | +) |
| 354 | + |
| 355 | +# Add series to trigger pygal rendering pipeline |
| 356 | +chart.add("chromagram", [0]) |
| 357 | + |
| 358 | +# Render outputs using pygal's native methods |
| 359 | +chart.render_to_file("plot.svg") |
| 360 | +chart.render_to_png("plot.png") |
| 361 | + |
| 362 | +html_content = f"""<!DOCTYPE html> |
| 363 | +<html> |
| 364 | +<head> |
| 365 | + <meta charset="utf-8"> |
| 366 | + <title>heatmap-chromagram - pygal</title> |
| 367 | + <style> |
| 368 | + body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f5f5f5; }} |
| 369 | + .chart {{ max-width: 100%; height: auto; }} |
| 370 | + </style> |
| 371 | +</head> |
| 372 | +<body> |
| 373 | + <figure class="chart"> |
| 374 | + {chart.render(is_unicode=True)} |
| 375 | + </figure> |
| 376 | +</body> |
| 377 | +</html> |
| 378 | +""" |
| 379 | + |
| 380 | +with open("plot.html", "w", encoding="utf-8") as f: |
| 381 | + f.write(html_content) |
0 commit comments