{"spec_id":"spectrogram-mel","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nspectrogram-mel: Mel-Spectrogram for Audio Analysis\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 80/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\nfrom scipy import signal\n\n\n# Remove script directory from sys.path to avoid pygal package name collision\nsys.path = [p for p in sys.path if os.path.abspath(p) != os.path.dirname(os.path.abspath(__file__))]\n\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\ntitle_str = \"spectrogram-mel · python · pygal · anyplot.ai\"\nn_title = len(title_str)\ntitle_font_size = round(66 * 67 / n_title) if n_title > 67 else 66\n\n\ndef interpolate_color(value, min_val, max_val, colormap):\n    normalized = max(0.0, min(1.0, (value - min_val) / (max_val - min_val))) if max_val != min_val else 1.0\n    pos = normalized * (len(colormap) - 1)\n    lo, hi = int(pos), min(int(pos) + 1, len(colormap) - 1)\n    frac = pos - lo\n    c1, c2 = colormap[lo], colormap[hi]\n    r = int(int(c1[1:3], 16) + (int(c2[1:3], 16) - int(c1[1:3], 16)) * frac)\n    g = int(int(c1[3:5], 16) + (int(c2[3:5], 16) - int(c1[3:5], 16)) * frac)\n    b = int(int(c1[5:7], 16) + (int(c2[5:7], 16) - int(c1[5:7], 16)) * frac)\n    return f\"#{r:02x}{g:02x}{b:02x}\"\n\n\nclass MelSpectrogramChart(Graph):\n    \"\"\"Custom mel-spectrogram chart extending pygal's Graph.\"\"\"\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.mel_freq_labels = kwargs.pop(\"mel_freq_labels\", [])\n        self.mel_freq_positions = kwargs.pop(\"mel_freq_positions\", [])\n        self.note_annotations = kwargs.pop(\"note_annotations\", [])\n        self.db_min = kwargs.pop(\"db_min\", -80)\n        self.db_max = kwargs.pop(\"db_max\", 0)\n        self.colormap = kwargs.pop(\"colormap\", [])\n        super().__init__(*args, **kwargs)\n\n    def _plot(self):\n        if len(self.spectrogram_data) == 0:\n            return\n\n        n_mels = len(self.spectrogram_data)\n        n_time = len(self.spectrogram_data[0]) if n_mels > 0 else 0\n        min_val, max_val = self.db_min, self.db_max\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        margin_left, margin_right = 160, 200\n        margin_top, margin_bottom = 30, 100\n\n        avail_w = plot_width - margin_left - margin_right\n        avail_h = plot_height - margin_bottom - margin_top\n        cell_w = avail_w / n_time\n        cell_h = avail_h / n_mels\n\n        x0 = self.view.x(0) + margin_left\n        y0 = self.view.y(n_mels) + margin_top\n\n        plot_node = self.nodes[\"plot\"]\n        grp = self.svg.node(plot_node, class_=\"mel-spectrogram\")\n\n        # Draw spectrogram cells (mel band 0 at bottom)\n        for i in range(n_mels):\n            for j in range(n_time):\n                value = self.spectrogram_data[n_mels - 1 - i][j]\n                color = interpolate_color(value, min_val, max_val, self.colormap)\n                rect = self.svg.node(\n                    grp, \"rect\", x=x0 + j * cell_w, y=y0 + i * cell_h, width=cell_w + 0.5, height=cell_h + 0.5\n                )\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", \"none\")\n\n        # Border around spectrogram\n        border = self.svg.node(grp, \"rect\", x=x0, y=y0, width=avail_w, height=avail_h)\n        border.set(\"fill\", \"none\")\n        border.set(\"stroke\", INK_SOFT)\n        border.set(\"stroke-width\", \"2\")\n\n        axis_label_size = 56\n        tick_font_size = 44\n\n        # X-axis label\n        tx = self.svg.node(grp, \"text\", x=x0 + avail_w / 2, y=y0 + avail_h + 90)\n        tx.set(\"text-anchor\", \"middle\")\n        tx.set(\"fill\", INK)\n        tx.set(\"style\", f\"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif\")\n        tx.text = \"Time (s)\"\n\n        # Y-axis label\n        yl_x, yl_y = x0 - 130, y0 + avail_h / 2\n        ty = self.svg.node(grp, \"text\", x=yl_x, y=yl_y, transform=f\"rotate(-90, {yl_x}, {yl_y})\")\n        ty.set(\"text-anchor\", \"middle\")\n        ty.set(\"fill\", INK)\n        ty.set(\"style\", f\"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif\")\n        ty.text = \"Frequency (Hz)\"\n\n        # X-axis ticks\n        n_x_ticks = 7\n        for i in range(n_x_ticks):\n            frac = i / (n_x_ticks - 1)\n            tick_x = x0 + frac * avail_w\n            tick_y = y0 + avail_h\n            line = self.svg.node(grp, \"line\", x1=tick_x, y1=tick_y, x2=tick_x, y2=tick_y + 12)\n            line.set(\"stroke\", INK_SOFT)\n            line.set(\"stroke-width\", \"2\")\n            time_val = self.time_bins[int(frac * (len(self.time_bins) - 1))]\n            tt = self.svg.node(grp, \"text\", x=tick_x, y=tick_y + 48)\n            tt.set(\"text-anchor\", \"middle\")\n            tt.set(\"fill\", INK_SOFT)\n            tt.set(\"style\", f\"font-size:{tick_font_size}px;font-family:sans-serif\")\n            tt.text = f\"{time_val:.1f}\"\n\n        # Y-axis ticks\n        for freq_hz, norm_pos in zip(self.mel_freq_labels, self.mel_freq_positions, strict=True):\n            tick_x = x0\n            tick_y = y0 + (1 - norm_pos) * avail_h\n            line = self.svg.node(grp, \"line\", x1=tick_x - 12, y1=tick_y, x2=tick_x, y2=tick_y)\n            line.set(\"stroke\", INK_SOFT)\n            line.set(\"stroke-width\", \"2\")\n            lbl = self.svg.node(grp, \"text\", x=tick_x - 20, y=tick_y + 14)\n            lbl.set(\"text-anchor\", \"end\")\n            lbl.set(\"fill\", INK_SOFT)\n            lbl.set(\"style\", f\"font-size:{tick_font_size}px;font-family:sans-serif\")\n            lbl.text = f\"{freq_hz / 1000:.1f}k\" if freq_hz >= 1000 else f\"{int(freq_hz)}\"\n\n        # Note annotations at each onset for storytelling emphasis\n        annot_size = 44\n        for note_name, norm_y, time_frac in self.note_annotations:\n            ax = x0 + time_frac * avail_w\n            ay = y0 + (1 - norm_y) * avail_h\n            # Circle markers with dual-color rings for contrast on spectrogram\n            marker = self.svg.node(grp, \"circle\", cx=ax, cy=ay, r=16)\n            marker.set(\"fill\", \"none\")\n            marker.set(\"stroke\", \"#000000\")\n            marker.set(\"stroke-width\", \"4\")\n            marker.set(\"opacity\", \"0.6\")\n            marker2 = self.svg.node(grp, \"circle\", cx=ax, cy=ay, r=16)\n            marker2.set(\"fill\", \"none\")\n            marker2.set(\"stroke\", \"#ffffff\")\n            marker2.set(\"stroke-width\", \"2.5\")\n            # Text shadow for legibility over varying spectrogram colors\n            shadow = self.svg.node(grp, \"text\", x=ax + 22, y=ay + 6)\n            shadow.set(\"fill\", \"#000000\")\n            shadow.set(\"opacity\", \"0.8\")\n            shadow.set(\"style\", f\"font-size:{annot_size}px;font-weight:bold;font-family:sans-serif\")\n            shadow.text = note_name\n            at = self.svg.node(grp, \"text\", x=ax + 20, y=ay + 4)\n            at.set(\"fill\", \"#ffffff\")\n            at.set(\"style\", f\"font-size:{annot_size}px;font-weight:bold;font-family:sans-serif\")\n            at.text = note_name\n\n        # Colorbar\n        cb_w, cb_h = 45, avail_h * 0.85\n        cb_x = x0 + avail_w + 40\n        cb_y = y0 + (avail_h - cb_h) / 2\n\n        n_segments = 100\n        seg_h = cb_h / n_segments\n        for i in range(n_segments):\n            seg_val = min_val + (max_val - min_val) * (n_segments - 1 - i) / (n_segments - 1)\n            seg_color = interpolate_color(seg_val, min_val, max_val, self.colormap)\n            self.svg.node(grp, \"rect\", x=cb_x, y=cb_y + i * seg_h, width=cb_w, height=seg_h + 1, fill=seg_color)\n\n        # Colorbar border\n        self.svg.node(grp, \"rect\", x=cb_x, y=cb_y, width=cb_w, height=cb_h, fill=\"none\", stroke=INK_SOFT)\n\n        # Colorbar ticks and labels\n        cb_label_size = 44\n        for i in range(6):\n            pos = i / 5\n            val = max_val - (max_val - min_val) * pos\n            ty_pos = cb_y + pos * cb_h + cb_label_size * 0.35\n            tl = self.svg.node(\n                grp, \"line\", x1=cb_x + cb_w, y1=cb_y + pos * cb_h, x2=cb_x + cb_w + 10, y2=cb_y + pos * cb_h\n            )\n            tl.set(\"stroke\", INK_SOFT)\n            tl.set(\"stroke-width\", \"2\")\n            ct = self.svg.node(grp, \"text\", x=cb_x + cb_w + 18, y=ty_pos)\n            ct.set(\"fill\", INK_SOFT)\n            ct.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n            ct.text = f\"{val:.0f}\"\n\n        # Colorbar title\n        cbt = self.svg.node(grp, \"text\", x=cb_x + cb_w / 2, y=cb_y - 22)\n        cbt.set(\"text-anchor\", \"middle\")\n        cbt.set(\"fill\", INK)\n        cbt.set(\"style\", f\"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif\")\n        cbt.text = \"dB\"\n\n    def _compute(self):\n        n_mels = len(self.spectrogram_data) if self.spectrogram_data else 1\n        n_time = len(self.spectrogram_data[0]) if self.spectrogram_data and self.spectrogram_data[0] else 1\n        self._box.xmin = 0\n        self._box.xmax = n_time\n        self._box.ymin = 0\n        self._box.ymax = n_mels\n\n\n# Data\nnp.random.seed(42)\n\nsample_rate = 22050\nduration = 3.0\nt = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)\n\n# C-major arpeggio: C4 → E4 → G4 → C5 → G4 → E4 (ascending then descending)\nnote_names = [\"C4\", \"E4\", \"G4\", \"C5\", \"G4\", \"E4\"]\nnote_freqs = [261.6, 329.6, 392.0, 523.3, 392.0, 329.6]\nnote_duration = duration / len(note_freqs)\naudio_signal = np.zeros_like(t)\n\nfor idx, freq in enumerate(note_freqs):\n    start = int(idx * note_duration * sample_rate)\n    end = int((idx + 1) * note_duration * sample_rate)\n    note_t = t[start:end] - t[start]\n    envelope = np.exp(-2.0 * note_t / note_duration)\n    audio_signal[start:end] += envelope * np.sin(2 * np.pi * freq * note_t)\n    audio_signal[start:end] += 0.5 * envelope * np.sin(2 * np.pi * 2 * freq * note_t)\n    audio_signal[start:end] += 0.25 * envelope * np.sin(2 * np.pi * 3 * freq * note_t)\n\naudio_signal += 0.02 * np.random.randn(len(t))\n\n# Mel spectrogram computation (manual filterbank — no librosa dependency)\nn_fft = 2048\nhop_length = 512\nn_mels_count = 128\nfmax = sample_rate / 2.0\nmel_min = 2595.0 * np.log10(1.0 + 0 / 700.0)\nmel_max = 2595.0 * np.log10(1.0 + fmax / 700.0)\nmel_points = np.linspace(mel_min, mel_max, n_mels_count + 2)\nhz_points = 700.0 * (10.0 ** (mel_points / 2595.0) - 1.0)\nbin_points = np.floor((n_fft + 1) * hz_points / sample_rate).astype(int)\n\nmel_filters = np.zeros((n_mels_count, n_fft // 2 + 1))\nfor m in range(1, n_mels_count + 1):\n    f_left, f_center, f_right = bin_points[m - 1], bin_points[m], bin_points[m + 1]\n    for k in range(f_left, f_center):\n        if f_center != f_left:\n            mel_filters[m - 1, k] = (k - f_left) / (f_center - f_left)\n    for k in range(f_center, f_right):\n        if f_right != f_center:\n            mel_filters[m - 1, k] = (f_right - k) / (f_right - f_center)\n\n# STFT\n_, times_stft, Zxx = signal.stft(audio_signal, fs=sample_rate, nperseg=n_fft, noverlap=n_fft - hop_length)\npower_spectrum = np.abs(Zxx) ** 2\n\n# Apply mel filterbank and convert to dB\nmel_spec = mel_filters @ power_spectrum\nmel_spec_db = 10 * np.log10(mel_spec + 1e-10)\ndb_max_val = np.max(mel_spec_db)\nmel_spec_db = mel_spec_db - db_max_val\ndb_floor = -80.0\nmel_spec_db = np.maximum(mel_spec_db, db_floor)\n\n# Subsample for display resolution\ntime_step = max(1, len(times_stft) // 220)\nmel_step = max(1, n_mels_count // 128)\nmel_spec_display = mel_spec_db[::mel_step, ::time_step]\ntimes_display = times_stft[::time_step]\n\n# Y-axis tick positions (mel-scaled Hz labels)\ntick_freqs_hz = [100, 200, 500, 1000, 2000, 4000, 8000]\nmel_tick_positions = []\nmel_tick_labels = []\nfor f in tick_freqs_hz:\n    norm_pos = 2595.0 * np.log10(1.0 + f / 700.0) / mel_max\n    if 0 <= norm_pos <= 1:\n        mel_tick_positions.append(norm_pos)\n        mel_tick_labels.append(f)\n\n# Note onset annotations for storytelling\nannotations = []\nfor idx, (name, freq) in enumerate(zip(note_names, note_freqs, strict=True)):\n    time_frac = (idx * note_duration + note_duration * 0.08) / duration\n    freq_norm = 2595.0 * np.log10(1.0 + freq / 700.0) / mel_max\n    annotations.append((name, freq_norm, time_frac))\n\n# Imprint sequential colormap: #4467A3 (low dB) → #009E73 (high dB)\nimprint_seq = [\"#4467A3\", \"#009E73\"]\n\n# Plot style with Imprint palette and theme-adaptive chrome\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    title_font_size=title_font_size,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    font_family=\"sans-serif\",\n)\n\n# Chart — 3200×1800 landscape canvas (hard rule: no deviation)\nchart = MelSpectrogramChart(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title_str,\n    spectrogram_data=mel_spec_display.tolist(),\n    time_bins=times_display.tolist(),\n    mel_freq_labels=mel_tick_labels,\n    mel_freq_positions=mel_tick_positions,\n    note_annotations=annotations,\n    db_min=db_floor,\n    db_max=0,\n    colormap=imprint_seq,\n    show_legend=False,\n    margin=40,\n    margin_top=120,\n    margin_bottom=30,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\nchart.add(\"\", [0])\n\n# Save PNG + HTML (pygal is interactive)\nchart.render_to_file(f\"plot-{THEME}.svg\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>spectrogram-mel - python - pygal - anyplot.ai</title>\n    <style>\n        body {{ margin: 0; display: flex; justify-content: center; align-items: center;\n               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"}