{"spec_id":"spectrogram-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nspectrogram-basic: Spectrogram Time-Frequency Heatmap\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-15\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\nfrom scipy import signal\n\n\n# Temporarily remove current directory from path to avoid name collision\n_cwd = sys.path[0] if sys.path[0] else \".\"\nif _cwd in sys.path:\n    sys.path.remove(_cwd)\n\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\n# Restore path\nsys.path.insert(0, _cwd)\n\n# Theme tokens (from default-style-guide.md)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nGRID_COLOR = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\n\nclass SpectrogramHeatmap(Graph):\n    \"\"\"Custom Spectrogram visualization for pygal - displays time-frequency representation.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self.spectrogram_data = kwargs.pop(\"spectrogram_data\", [])\n        self.time_bins = kwargs.pop(\"time_bins\", [])\n        self.freq_bins = kwargs.pop(\"freq_bins\", [])\n        self.colormap = kwargs.pop(\n            \"colormap\",\n            [\n                \"#440154\",\n                \"#482878\",\n                \"#3e4a89\",\n                \"#31688e\",\n                \"#26828e\",\n                \"#1f9e89\",\n                \"#35b779\",\n                \"#6ece58\",\n                \"#b5de2b\",\n                \"#fde725\",\n            ],\n        )\n        self.ink_color = kwargs.pop(\"ink_color\", INK)\n        self.grid_color_rgb = kwargs.pop(\"grid_color_rgb\", GRID_COLOR)\n        super().__init__(*args, **kwargs)\n\n    def _interpolate_color(self, value, min_val, max_val):\n        \"\"\"Interpolate color for smooth gradient.\"\"\"\n        if max_val == min_val:\n            return self.colormap[-1]\n\n        normalized = (value - min_val) / (max_val - min_val)\n        normalized = max(0, min(1, normalized))\n\n        pos = normalized * (len(self.colormap) - 1)\n        idx1 = int(pos)\n        idx2 = min(idx1 + 1, len(self.colormap) - 1)\n        frac = pos - idx1\n\n        c1 = self.colormap[idx1]\n        c2 = self.colormap[idx2]\n\n        r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)\n        r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16)\n\n        r = int(r1 + (r2 - r1) * frac)\n        g = int(g1 + (g2 - g1) * frac)\n        b = int(b1 + (b2 - b1) * frac)\n\n        return f\"#{r:02x}{g:02x}{b:02x}\"\n\n    def _plot(self):\n        \"\"\"Draw the spectrogram heatmap.\"\"\"\n        if len(self.spectrogram_data) == 0:\n            return\n\n        n_freq = len(self.spectrogram_data)\n        n_time = len(self.spectrogram_data[0]) if n_freq > 0 else 0\n\n        # Find value range\n        min_val = np.min(self.spectrogram_data)\n        max_val = np.max(self.spectrogram_data)\n\n        # Get plot dimensions\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        # Calculate margins for labels\n        label_margin_left = 280\n        label_margin_bottom = 200\n        label_margin_top = 80\n        label_margin_right = 280\n\n        available_width = plot_width - label_margin_left - label_margin_right\n        available_height = plot_height - label_margin_bottom - label_margin_top\n\n        cell_width = available_width / n_time\n        cell_height = available_height / n_freq\n\n        x_offset = self.view.x(0) + label_margin_left\n        y_offset = self.view.y(n_freq) + label_margin_top\n\n        # Create group for the spectrogram\n        plot_node = self.nodes[\"plot\"]\n        spec_group = self.svg.node(plot_node, class_=\"spectrogram-heatmap\")\n\n        # Draw cells (frequency is from top to bottom, highest freq at top)\n        for i in range(n_freq):\n            for j in range(n_time):\n                value = self.spectrogram_data[n_freq - 1 - i][j]\n                color = self._interpolate_color(value, min_val, max_val)\n\n                x = x_offset + j * cell_width\n                y = y_offset + i * cell_height\n\n                rect = self.svg.node(spec_group, \"rect\", x=x, y=y, width=cell_width + 0.5, height=cell_height + 0.5)\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", \"none\")\n\n        # Draw subtle grid lines to help read values\n        grid_alpha = 0.10\n        n_grid_x = 6\n        n_grid_y = 6\n\n        # Vertical grid lines\n        for i in range(1, n_grid_x):\n            grid_x = x_offset + (i / n_grid_x) * available_width\n            grid_line = self.svg.node(\n                spec_group, \"line\", x1=grid_x, y1=y_offset, x2=grid_x, y2=y_offset + available_height\n            )\n            grid_line.set(\"stroke\", self.ink_color)\n            grid_line.set(\"stroke-width\", \"1\")\n            grid_line.set(\"opacity\", str(grid_alpha))\n\n        # Horizontal grid lines\n        for i in range(1, n_grid_y):\n            grid_y = y_offset + (i / n_grid_y) * available_height\n            grid_line = self.svg.node(\n                spec_group, \"line\", x1=x_offset, y1=grid_y, x2=x_offset + available_width, y2=grid_y\n            )\n            grid_line.set(\"stroke\", self.ink_color)\n            grid_line.set(\"stroke-width\", \"1\")\n            grid_line.set(\"opacity\", str(grid_alpha))\n\n        # Draw axes border\n        border = self.svg.node(\n            spec_group, \"rect\", x=x_offset, y=y_offset, width=available_width, height=available_height\n        )\n        border.set(\"fill\", \"none\")\n        border.set(\"stroke\", self.ink_color)\n        border.set(\"stroke-width\", \"3\")\n\n        # Draw x-axis label (Time)\n        x_label_size = 52\n        x_label_x = x_offset + available_width / 2\n        x_label_y = y_offset + available_height + 150\n        text_node = self.svg.node(spec_group, \"text\", x=x_label_x, y=x_label_y)\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", self.ink_color)\n        text_node.set(\"style\", f\"font-size:{x_label_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.text = \"Time (s)\"\n\n        # Draw y-axis label (Frequency)\n        y_label_size = 52\n        y_label_x = x_offset - 180\n        y_label_y = y_offset + available_height / 2\n        text_node = self.svg.node(\n            spec_group, \"text\", x=y_label_x, y=y_label_y, transform=f\"rotate(-90, {y_label_x}, {y_label_y})\"\n        )\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", self.ink_color)\n        text_node.set(\"style\", f\"font-size:{y_label_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.text = \"Frequency (Hz)\"\n\n        # Draw x-axis ticks and labels\n        tick_font_size = 38\n        n_x_ticks = 6\n        for i in range(n_x_ticks):\n            tick_x = x_offset + (i / (n_x_ticks - 1)) * available_width\n            tick_y = y_offset + available_height\n\n            # Tick line\n            line = self.svg.node(spec_group, \"line\", x1=tick_x, y1=tick_y, x2=tick_x, y2=tick_y + 15)\n            line.set(\"stroke\", self.ink_color)\n            line.set(\"stroke-width\", \"2\")\n\n            # Tick label\n            time_val = self.time_bins[int(i / (n_x_ticks - 1) * (len(self.time_bins) - 1))]\n            text_node = self.svg.node(spec_group, \"text\", x=tick_x, y=tick_y + 55)\n            text_node.set(\"text-anchor\", \"middle\")\n            text_node.set(\"fill\", self.ink_color)\n            text_node.set(\"style\", f\"font-size:{tick_font_size}px;font-family:sans-serif\")\n            text_node.text = f\"{time_val:.1f}\"\n\n        # Draw y-axis ticks and labels\n        n_y_ticks = 6\n        for i in range(n_y_ticks):\n            tick_x = x_offset\n            tick_y = y_offset + (i / (n_y_ticks - 1)) * available_height\n\n            # Tick line\n            line = self.svg.node(spec_group, \"line\", x1=tick_x - 15, y1=tick_y, x2=tick_x, y2=tick_y)\n            line.set(\"stroke\", self.ink_color)\n            line.set(\"stroke-width\", \"2\")\n\n            # Tick label (frequency decreases from top to bottom)\n            freq_idx = int((1 - i / (n_y_ticks - 1)) * (len(self.freq_bins) - 1))\n            freq_val = self.freq_bins[freq_idx]\n            text_node = self.svg.node(spec_group, \"text\", x=tick_x - 25, y=tick_y + 12)\n            text_node.set(\"text-anchor\", \"end\")\n            text_node.set(\"fill\", self.ink_color)\n            text_node.set(\"style\", f\"font-size:{tick_font_size}px;font-family:sans-serif\")\n            text_node.text = f\"{freq_val:.0f}\"\n\n        # Draw colorbar\n        colorbar_width = 50\n        colorbar_height = available_height * 0.8\n        colorbar_x = x_offset + available_width + 60\n        colorbar_y = y_offset + (available_height - colorbar_height) / 2\n\n        # Draw gradient colorbar\n        n_segments = 80\n        segment_height = colorbar_height / n_segments\n        for i in range(n_segments):\n            seg_value = min_val + (max_val - min_val) * (n_segments - 1 - i) / (n_segments - 1)\n            seg_color = self._interpolate_color(seg_value, min_val, max_val)\n            seg_y = colorbar_y + i * segment_height\n\n            self.svg.node(\n                spec_group,\n                \"rect\",\n                x=colorbar_x,\n                y=seg_y,\n                width=colorbar_width,\n                height=segment_height + 1,\n                fill=seg_color,\n            )\n\n        # Colorbar border\n        self.svg.node(\n            spec_group,\n            \"rect\",\n            x=colorbar_x,\n            y=colorbar_y,\n            width=colorbar_width,\n            height=colorbar_height,\n            fill=\"none\",\n            stroke=self.ink_color,\n        )\n\n        # Colorbar labels - 6 tick marks for more granular scale\n        cb_label_size = 36\n        n_cb_ticks = 6\n        cb_positions = [i / (n_cb_ticks - 1) for i in range(n_cb_ticks)]\n        cb_values = [max_val - (max_val - min_val) * pos for pos in cb_positions]\n        for val, pos in zip(cb_values, cb_positions, strict=True):\n            text_y = colorbar_y + pos * colorbar_height + cb_label_size * 0.35\n            # Add tick line on colorbar\n            tick_line = self.svg.node(\n                spec_group,\n                \"line\",\n                x1=colorbar_x + colorbar_width,\n                y1=colorbar_y + pos * colorbar_height,\n                x2=colorbar_x + colorbar_width + 10,\n                y2=colorbar_y + pos * colorbar_height,\n            )\n            tick_line.set(\"stroke\", self.ink_color)\n            tick_line.set(\"stroke-width\", \"2\")\n            text_node = self.svg.node(spec_group, \"text\", x=colorbar_x + colorbar_width + 20, y=text_y)\n            text_node.set(\"fill\", self.ink_color)\n            text_node.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n            text_node.text = f\"{val:.0f}\"\n\n        # Colorbar title\n        cb_title_size = 42\n        cb_title_x = colorbar_x + colorbar_width / 2\n        cb_title_y = colorbar_y - 30\n        text_node = self.svg.node(spec_group, \"text\", x=cb_title_x, y=cb_title_y)\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", self.ink_color)\n        text_node.set(\"style\", f\"font-size:{cb_title_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.text = \"Power (dB)\"\n\n    def _compute(self):\n        \"\"\"Compute the box for rendering.\"\"\"\n        n_freq = len(self.spectrogram_data) if len(self.spectrogram_data) > 0 else 1\n        n_time = (\n            len(self.spectrogram_data[0]) if len(self.spectrogram_data) > 0 and len(self.spectrogram_data[0]) > 0 else 1\n        )\n        self._box.xmin = 0\n        self._box.xmax = n_time\n        self._box.ymin = 0\n        self._box.ymax = n_freq\n\n\n# Generate data - chirp signal with increasing frequency\nnp.random.seed(42)\n\n# Signal parameters\nsample_rate = 4000  # Hz\nduration = 2.0  # seconds\nt = np.linspace(0, duration, int(sample_rate * duration))\n\n# Create chirp signal: frequency increases from 100 Hz to 800 Hz\nf0 = 100  # Start frequency\nf1 = 800  # End frequency\nchirp_signal = signal.chirp(t, f0=f0, f1=f1, t1=duration, method=\"linear\")\n\n# Add some harmonics and noise for interest\nchirp_signal += 0.3 * signal.chirp(t, f0=f0 * 2, f1=f1 * 1.5, t1=duration, method=\"linear\")\nchirp_signal += 0.1 * np.random.randn(len(t))\n\n# Compute spectrogram\nnperseg = 256\nnoverlap = 200\nfrequencies, times, Sxx = signal.spectrogram(chirp_signal, fs=sample_rate, nperseg=nperseg, noverlap=noverlap)\n\n# Convert to dB scale\nSxx_db = 10 * np.log10(Sxx + 1e-10)\n\n# Downsample for visualization (pygal renders individual cells)\n# Higher resolution for smoother appearance while maintaining performance\nfreq_step = max(1, len(frequencies) // 80)\ntime_step = max(1, len(times) // 128)\n\nfreq_subset = frequencies[::freq_step]\ntime_subset = times[::time_step]\nSxx_subset = Sxx_db[::freq_step, ::time_step]\n\n# Custom style (theme-adaptive)\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_SOFT,\n    colors=(\"#009E73\",),\n    title_font_size=72,\n    legend_font_size=48,\n    label_font_size=42,\n    value_font_size=36,\n    font_family=\"sans-serif\",\n)\n\n# Viridis colormap for perceptually uniform magnitude representation\nviridis_colormap = [\n    \"#440154\",\n    \"#482878\",\n    \"#3e4a89\",\n    \"#31688e\",\n    \"#26828e\",\n    \"#1f9e89\",\n    \"#35b779\",\n    \"#6ece58\",\n    \"#b5de2b\",\n    \"#fde725\",\n]\n\n# Create spectrogram chart (16:9 aspect ratio)\nchart = SpectrogramHeatmap(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"spectrogram-basic · pygal · anyplot.ai\",\n    spectrogram_data=Sxx_subset.tolist(),\n    time_bins=time_subset.tolist(),\n    freq_bins=freq_subset.tolist(),\n    colormap=viridis_colormap,\n    ink_color=INK,\n    grid_color_rgb=GRID_COLOR,\n    show_legend=False,\n    margin=120,\n    margin_top=200,\n    margin_bottom=100,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\n# Add a dummy series to trigger _plot\nchart.add(\"\", [0])\n\n# Save output with theme-suffixed filenames\nchart.render_to_file(f\"plot-{THEME}.svg\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n\n# Also save HTML for interactivity\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>spectrogram-basic - pygal</title>\n    <style>\n        body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: {PAGE_BG}; }}\n        .chart {{ max-width: 100%; height: auto; }}\n    </style>\n</head>\n<body>\n    <figure class=\"chart\">\n        {chart.render(is_unicode=True)}\n    </figure>\n</body>\n</html>\n\"\"\"\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}