{"spec_id":"spectrogram-mel","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nspectrogram-mel: Mel-Spectrogram for Audio Analysis\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# ── Theme tokens ──────────────────────────────────────────────────────────────\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\nTW, TH = 3200, 1800  # landscape canvas target\n\n# ── Data: synthesized audio signal ───────────────────────────────────────────\nnp.random.seed(42)\nsample_rate = 22050\nduration = 4.0\nn_samples = int(sample_rate * duration)\nt = np.linspace(0, duration, n_samples, endpoint=False)\n\n# Descending frequency sweep from 1200 Hz → 300 Hz with harmonics\nsweep_freq = np.cumsum(1200 * np.exp(-0.35 * t)) / sample_rate\nsignal = 0.6 * np.sin(2 * np.pi * sweep_freq)\nsignal += 0.3 * np.sin(2 * np.pi * 2 * sweep_freq)\nsignal += 0.15 * np.sin(2 * np.pi * 3 * sweep_freq)\n\n# Pulsed 440 Hz tone (A4) with amplitude modulation\nenvelope = 0.5 * (1 + np.sin(2 * np.pi * 2.5 * t))\nsignal += 0.4 * envelope * np.sin(2 * np.pi * 440 * t)\n\n# High-frequency chirp burst in the 1.5–2.5 s window\nchirp_mask = (t > 1.5) & (t < 2.5)\nchirp_phase = np.cumsum(chirp_mask * (2000 + 3000 * (t - 1.5))) / sample_rate\nsignal += 0.35 * chirp_mask * np.sin(2 * np.pi * chirp_phase)\n\n# Subtle noise floor\nsignal += 0.05 * np.random.randn(n_samples)\n\n# STFT\nn_fft = 2048\nhop_length = 512\nwindow = np.hanning(n_fft)\nn_freq_bins = n_fft // 2 + 1\nn_frames = 1 + (n_samples - n_fft) // hop_length\n\nstft_power = np.zeros((n_freq_bins, n_frames))\nfor i in range(n_frames):\n    start = i * hop_length\n    frame = signal[start : start + n_fft] * window\n    stft_power[:, i] = np.abs(np.fft.rfft(frame)) ** 2\n\n# Mel filter bank\nn_mels = 128\nf_max = sample_rate / 2.0\nmel_max = 2595.0 * np.log10(1.0 + f_max / 700.0)\nmel_edges = np.linspace(0, mel_max, n_mels + 2)\nhz_edges = 700.0 * (10.0 ** (mel_edges / 2595.0) - 1.0)\nfft_freqs = np.linspace(0, f_max, n_freq_bins)\n\nfilterbank = np.zeros((n_mels, n_freq_bins))\nfor i in range(n_mels):\n    lo, mid, hi = hz_edges[i], hz_edges[i + 1], hz_edges[i + 2]\n    up = (fft_freqs >= lo) & (fft_freqs <= mid)\n    dn = (fft_freqs > mid) & (fft_freqs <= hi)\n    if mid > lo:\n        filterbank[i, up] = (fft_freqs[up] - lo) / (mid - lo)\n    if hi > mid:\n        filterbank[i, dn] = (hi - fft_freqs[dn]) / (hi - mid)\n\nmel_spec = np.maximum(filterbank @ stft_power, 1e-10)\nmel_spec_db = 10.0 * np.log10(mel_spec)\nmel_spec_db -= mel_spec_db.max()\nmel_spec_db = np.maximum(mel_spec_db, -80.0)\n\n# Subsample time frames only; use all 128 mel bins for resolution\nframe_step = 2\ntime_idx = np.arange(0, n_frames, frame_step)\ntime_sec = time_idx * hop_length / sample_rate\ntime_width = frame_step * hop_length / sample_rate\n\nrows = []\nfor mi in range(n_mels):\n    freq_lo = float(hz_edges[mi])\n    freq_hi = float(hz_edges[mi + 2])\n    for ti_pos, ti in enumerate(time_idx):\n        rows.append(\n            {\n                \"t1\": round(float(time_sec[ti_pos]), 4),\n                \"t2\": round(float(time_sec[ti_pos]) + time_width, 4),\n                \"f1\": round(max(freq_lo, 20.0), 1),\n                \"f2\": round(freq_hi, 1),\n                \"dB\": round(float(mel_spec_db[mi, ti]), 1),\n            }\n        )\n\ndf = pd.DataFrame(rows)\n\n# Annotations — \"440 Hz Tone\" moved from x=3.5 → x=3.0 to avoid right-edge cramping\nannotations = pd.DataFrame(\n    [\n        {\"x\": 0.6, \"y\": 1200, \"label\": \"Harmonic Sweep\"},\n        {\"x\": 2.2, \"y\": 6500, \"label\": \"Chirp Burst\"},\n        {\"x\": 3.0, \"y\": 350, \"label\": \"440 Hz Tone\"},\n    ]\n)\n\n# ── Chart layers ──────────────────────────────────────────────────────────────\nspectrogram = (\n    alt.Chart(df)\n    .mark_rect()\n    .encode(\n        x=alt.X(\n            \"t1:Q\",\n            title=\"Time (s)\",\n            scale=alt.Scale(domain=[0, duration], nice=False),\n            axis=alt.Axis(values=[0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0], tickSize=5),\n        ),\n        x2=\"t2:Q\",\n        y=alt.Y(\n            \"f1:Q\",\n            title=\"Frequency (Hz)\",\n            scale=alt.Scale(type=\"log\", domain=[20, 11025], nice=False),\n            axis=alt.Axis(\n                values=[50, 100, 200, 500, 1000, 2000, 5000, 10000],\n                tickSize=5,\n                labelExpr=\"datum.value >= 1000 ? format(datum.value / 1000, '.0f') + 'k' : format(datum.value, '.0f')\",\n            ),\n        ),\n        y2=\"f2:Q\",\n        color=alt.Color(\n            \"dB:Q\",\n            scale=alt.Scale(range=[\"#009E73\", \"#4467A3\"], domain=[-80, 0]),\n            legend=alt.Legend(\n                title=\"Power (dB)\",\n                gradientLength=200,\n                gradientThickness=14,\n                titlePadding=8,\n                offset=12,\n                direction=\"vertical\",\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"t1:Q\", title=\"Time (s)\", format=\".2f\"),\n            alt.Tooltip(\"f1:Q\", title=\"Freq low (Hz)\", format=\".0f\"),\n            alt.Tooltip(\"f2:Q\", title=\"Freq high (Hz)\", format=\".0f\"),\n            alt.Tooltip(\"dB:Q\", title=\"Power (dB)\", format=\".1f\"),\n        ],\n    )\n)\n\nannotation_labels = (\n    alt.Chart(annotations)\n    .mark_text(\n        fontSize=13, fontWeight=\"bold\", color=\"#ffffff\", strokeWidth=3, stroke=\"#1a1a2e\", align=\"left\", dx=10, dy=-6\n    )\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"label:N\")\n)\n\nannotation_marks = (\n    alt.Chart(annotations)\n    .mark_point(shape=\"triangle-right\", size=120, color=\"#ffffff\", strokeWidth=2, stroke=\"#1a1a2e\", filled=True)\n    .encode(x=\"x:Q\", y=\"y:Q\")\n)\n\nchart = (\n    alt.layer(spectrogram, annotation_marks, annotation_labels)\n    .properties(\n        width=620,\n        height=320,\n        title=alt.Title(\n            \"spectrogram-mel · python · altair · anyplot.ai\",\n            subtitle=\"Synthesized signal: frequency sweep with harmonics, pulsed 440 Hz tone, and chirp burst\",\n            fontSize=22,\n            subtitleFontSize=14,\n            subtitleColor=INK_SOFT,\n            anchor=\"start\",\n            offset=16,\n            color=INK,\n        ),\n        padding={\"left\": 20, \"right\": 20, \"top\": 20, \"bottom\": 16},\n        background=PAGE_BG,\n    )\n    .configure_axis(\n        labelFontSize=11,\n        titleFontSize=14,\n        grid=False,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        tickSize=5,\n        labelPadding=6,\n        titlePadding=10,\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_title(color=INK)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n    )\n    .configure(font=\"Helvetica Neue, Helvetica, Arial, sans-serif\", background=PAGE_BG)\n)\n\n# ── Save ──────────────────────────────────────────────────────────────────────\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad to exact 3200×1800 (vl-convert may land slightly short of the target)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}