{"spec_id":"spectrogram-mel","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// spectrogram-mel: Mel-Spectrogram for Audio Analysis\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 82/100 | Created: 2026-08-24\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Synthetic audio (deterministic LCG, no Math.random) --------------------\nconst SAMPLE_RATE = 16000;\n// Rising-then-falling run (C3 E3 G3 C4 E4 G4 E4 C4) so the fundamental sweeps\n// visibly across the mel bins instead of hovering in a single narrow band.\nconst NOTES_HZ = [130.81, 164.81, 196.0, 261.63, 329.63, 392.0, 329.63, 261.63];\nconst NOTE_DURATION = 0.3;\nconst DURATION = NOTES_HZ.length * NOTE_DURATION;\nconst N_SAMPLES = Math.round(SAMPLE_RATE * DURATION);\n\nlet lcgState = 42;\nfunction lcgNext() {\n  lcgState = (lcgState * 1664525 + 1013904223) >>> 0;\n  return lcgState / 4294967296;\n}\n\nconst audio = new Float64Array(N_SAMPLES);\nfor (let n = 0; n < N_SAMPLES; n++) {\n  const time = n / SAMPLE_RATE;\n  const noteIdx = Math.min(NOTES_HZ.length - 1, Math.floor(time / NOTE_DURATION));\n  const timeInNote = time - noteIdx * NOTE_DURATION;\n  const vibrato = 1 + 0.004 * Math.sin(2 * Math.PI * 5 * time);\n  const f0 = NOTES_HZ[noteIdx] * vibrato;\n  // Raised-cosine (not linear) attack: a linear ramp has a corner in its\n  // derivative that splatters broadband energy across every mel bin at each\n  // onset, which was the real source of the \"noisy across the full\n  // frequency range\" texture — a smooth S-curve confines the onset's energy\n  // to the note's own partials instead of the whole spectrum.\n  const attack = timeInNote < 0.01 ? 0.5 - 0.5 * Math.cos((Math.PI * timeInNote) / 0.01) : 1;\n  // Fast pluck-like decay so each note's onset and the silence between notes\n  // both stay visible instead of blurring into one continuous tone; steep\n  // enough that consecutive notes don't smear together in the mel grid.\n  const envelope = attack * Math.exp(-22 * timeInNote);\n  // Fundamental plus a much quieter overtone: with only 48 mel bins packed\n  // into a narrow 80-850 Hz span, a loud second partial competes visually\n  // with the fundamental and reads as noise, so keep the overtone faint and\n  // let one dominant band trace the melody clearly.\n  const tone = Math.sin(2 * Math.PI * f0 * time) + 0.18 * Math.sin(2 * Math.PI * 2 * f0 * time);\n  const breathNoise = (lcgNext() - 0.5) * 0.006 * envelope;\n  audio[n] = 0.3 * envelope * tone + breathNoise;\n}\n\n// --- Mel-scale frequency centers --------------------------------------------\nconst N_MELS = 48;\nconst FMIN_HZ = 80;\n// Capped well below Nyquist so the fundamental + its faint overtone (up to\n// 392 Hz x 2 = 784 Hz) span most of the mel axis, leaving only a thin margin\n// of true noise floor above instead of a wide dead band.\nconst FMAX_HZ = 850;\nfunction hzToMel(hz) {\n  return 2595 * Math.log10(1 + hz / 700);\n}\nfunction melToHz(mel) {\n  return 700 * (Math.pow(10, mel / 2595) - 1);\n}\nconst melMin = hzToMel(FMIN_HZ);\nconst melMax = hzToMel(FMAX_HZ);\nconst melEdges = Array.from({ length: N_MELS + 1 }, (_, i) => melMin + ((melMax - melMin) * i) / N_MELS);\nconst hzEdges = melEdges.map(melToHz);\nconst melCenterHz = Array.from({ length: N_MELS }, (_, m) => melToHz((melEdges[m] + melEdges[m + 1]) / 2));\n\n// --- Short-time energy at each mel center via the Goertzel algorithm --------\n// (avoids computing a full FFT per frame — only the frequencies we need)\n// N_FFT=1024 gives a ~62.5 Hz main-lobe width (vs ~125 Hz at 512), narrower\n// relative to the mel-bin spacing at low frequencies, while staying short\n// enough that note onsets still land cleanly within a handful of frames.\nconst N_FFT = 1024;\nconst HOP = 384;\nconst N_FRAMES = Math.floor((N_SAMPLES - N_FFT) / HOP) + 1;\n\nfunction goertzelPower(samples, start, n, freq, sampleRate) {\n  const omega = (2 * Math.PI * freq) / sampleRate;\n  const coeff = 2 * Math.cos(omega);\n  let s1 = 0;\n  let s2 = 0;\n  for (let i = 0; i < n; i++) {\n    const hann = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1));\n    const s0 = samples[start + i] * hann + coeff * s1 - s2;\n    s2 = s1;\n    s1 = s0;\n  }\n  const real = s1 - s2 * Math.cos(omega);\n  const imag = s2 * Math.sin(omega);\n  return real * real + imag * imag;\n}\n\nconst powerMatrix = [];\nlet maxPower = 0;\nfor (let f = 0; f < N_FRAMES; f++) {\n  const start = f * HOP;\n  const row = new Array(N_MELS);\n  for (let m = 0; m < N_MELS; m++) {\n    const p = goertzelPower(audio, start, N_FFT, melCenterHz[m], SAMPLE_RATE);\n    row[m] = p;\n    if (p > maxPower) maxPower = p;\n  }\n  powerMatrix.push(row);\n}\n\n// power -> dB, referenced to peak power, floored for display dynamic range\n// (widened from -80 to -90 dB so near-floor texture spreads across more of\n// the color scale instead of clumping at one end)\nconst DB_FLOOR = -90;\nconst EPS = 1e-12;\nconst dbMatrix = powerMatrix.map((row) =>\n  row.map((p) => Math.max(DB_FLOOR, 10 * Math.log10((p + EPS) / (maxPower + EPS))))\n);\nconst normMatrix = dbMatrix.map((row) => row.map((db) => (db - DB_FLOOR) / -DB_FLOOR));\n\nconst timeEdges = Array.from({ length: N_FRAMES + 1 }, (_, f) => (f * HOP) / SAMPLE_RATE);\n\n// --- Imprint sequential colormap (green -> blue), no other cmap allowed -----\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction rgbToHex(r, g, b) {\n  return (\n    \"#\" +\n    [r, g, b]\n      .map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, \"0\"))\n      .join(\"\")\n  );\n}\n// Gamma > 1 compresses the color response near the noise floor (small dB\n// differences there barely shift hue, reading as a calm, quiet background)\n// while expanding it near the loud peaks, so real note energy stands out\n// instead of the whole grid looking like uniform colorful speckle.\nconst COLOR_GAMMA = 2.4;\nfunction imprintSeq(frac) {\n  const shaped = Math.pow(Math.max(0, Math.min(1, frac)), COLOR_GAMMA);\n  const a = hexToRgb(t.seq[0]);\n  const b = hexToRgb(t.seq[1]);\n  return rgbToHex(\n    a[0] + (b[0] - a[0]) * shaped,\n    a[1] + (b[1] - a[1]) * shaped,\n    a[2] + (b[2] - a[2]) * shaped\n  );\n}\n\n// --- Chart: axes + chrome only, the mel grid is drawn as native SVG rects ---\n// (the vendored core bundle has no heatmap module — chart.renderer.rect +\n// axis.toPixels() is the idiomatic core-only substitute)\nHighcharts.chart(\n  \"container\",\n  {\n    chart: {\n      type: \"scatter\",\n      backgroundColor: \"transparent\",\n      animation: false,\n      style: { fontFamily: \"inherit\" },\n      marginLeft: 110,\n      marginRight: 170,\n      marginTop: 90,\n      marginBottom: 100,\n    },\n    credits: { enabled: false },\n    title: {\n      text: \"spectrogram-mel · javascript · highcharts · anyplot.ai\",\n      style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n    },\n    subtitle: {\n      text: \"Synthetic melody · 2.4 s at 16 kHz · 48 mel bands\",\n      style: { color: t.inkSoft, fontSize: \"14px\" },\n    },\n    xAxis: {\n      title: { text: \"Time (s)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n      min: 0,\n      max: timeEdges[N_FRAMES],\n      tickInterval: 0.4,\n      gridLineWidth: 0,\n      lineColor: t.inkSoft,\n      tickColor: t.inkSoft,\n      labels: {\n        style: { color: t.inkSoft, fontSize: \"14px\" },\n        formatter() {\n          return this.value.toFixed(1);\n        },\n      },\n    },\n    yAxis: {\n      title: { text: \"Frequency (Hz, mel-scaled)\", style: { color: t.inkSoft, fontSize: \"16px\" } },\n      min: 0,\n      max: N_MELS,\n      tickPositions: [0, N_MELS * 0.25, N_MELS * 0.5, N_MELS * 0.75, N_MELS],\n      gridLineWidth: 0,\n      lineColor: t.inkSoft,\n      tickColor: t.inkSoft,\n      labels: {\n        style: { color: t.inkSoft, fontSize: \"14px\" },\n        formatter() {\n          return `${Math.round(hzEdges[this.value])} Hz`;\n        },\n      },\n    },\n    legend: { enabled: false },\n    tooltip: { enabled: false },\n    plotOptions: { series: { animation: false, enableMouseTracking: false } },\n    series: [{ data: [], showInLegend: false }],\n  },\n  function (chart) {\n    const xAxis = chart.xAxis[0];\n    const yAxis = chart.yAxis[0];\n\n    // Mel-power grid, cell-exact via axis.toPixels()\n    const cellsGroup = chart.renderer.g(\"mel-cells\").add();\n    cellsGroup.attr({ zIndex: 3 });\n    for (let f = 0; f < N_FRAMES; f++) {\n      const xLeft = xAxis.toPixels(timeEdges[f]);\n      const xRight = xAxis.toPixels(timeEdges[f + 1]);\n      for (let m = 0; m < N_MELS; m++) {\n        const yTop = yAxis.toPixels(m + 1);\n        const yBottom = yAxis.toPixels(m);\n        chart.renderer\n          .rect(Math.min(xLeft, xRight), Math.min(yTop, yBottom), Math.abs(xRight - xLeft) + 0.5, Math.abs(\n            yBottom - yTop\n          ) + 0.5)\n          .attr({ fill: imprintSeq(normMatrix[f][m]) })\n          .add(cellsGroup);\n      }\n    }\n\n    // Frame around the grid\n    chart.renderer\n      .rect(chart.plotLeft, chart.plotTop, chart.plotWidth, chart.plotHeight)\n      .attr({ stroke: t.inkSoft, \"stroke-width\": 1, fill: \"none\", zIndex: 4 })\n      .add();\n\n    // Manual colorbar (labeled in dB) — the substitute for a colorAxis legend\n    const barLeft = chart.plotLeft + chart.plotWidth + 40;\n    const barWidth = 26;\n    const steps = 120;\n    const barGroup = chart.renderer.g(\"colorbar\").add();\n    for (let s = 0; s < steps; s++) {\n      const frac = s / (steps - 1);\n      const yPos = chart.plotTop + chart.plotHeight - ((s + 1) / steps) * chart.plotHeight;\n      chart.renderer\n        .rect(barLeft, yPos, barWidth, chart.plotHeight / steps + 0.5)\n        .attr({ fill: imprintSeq(frac) })\n        .add(barGroup);\n    }\n    chart.renderer\n      .rect(barLeft, chart.plotTop, barWidth, chart.plotHeight)\n      .attr({ stroke: t.inkSoft, \"stroke-width\": 1, fill: \"none\" })\n      .add(barGroup);\n    [DB_FLOOR, DB_FLOOR / 2, 0].forEach((db) => {\n      const frac = (db - DB_FLOOR) / -DB_FLOOR;\n      const y = chart.plotTop + chart.plotHeight - frac * chart.plotHeight;\n      chart.renderer\n        .text(`${db} dB`, barLeft + barWidth + 10, y + 5)\n        .css({ color: t.inkSoft, fontSize: \"13px\" })\n        .add(barGroup);\n    });\n    chart.renderer\n      .text(\"Power (dB)\", barLeft - 6, chart.plotTop - 16)\n      .css({ color: t.inkSoft, fontSize: \"14px\" })\n      .add(barGroup);\n  }\n);\n"}