Skip to content

Commit 9f9cb1f

Browse files
feat(chartjs): implement spectrogram-mel (#8421)
## Implementation: `spectrogram-mel` - javascript/chartjs Implements the **javascript/chartjs** version of `spectrogram-mel`. **File:** `plots/spectrogram-mel/implementations/javascript/chartjs.js` **Parent Issue:** #4672 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/26903795847)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 786903a commit 9f9cb1f

2 files changed

Lines changed: 539 additions & 0 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// anyplot.ai
2+
// spectrogram-mel: Mel-Spectrogram for Audio Analysis
3+
// Library: chartjs 4.4.7 | JavaScript 22.22.3
4+
// Quality: 91/100 | Created: 2026-06-03
5+
//# anyplot-orientation: landscape
6+
7+
const t = window.ANYPLOT_TOKENS;
8+
9+
// --- Configuration ---------------------------------------------------------
10+
const N_MELS = 64;
11+
const N_FRAMES = 250;
12+
const DURATION = 5.0;
13+
const SAMPLE_RATE = 22050;
14+
const DB_MIN = -80;
15+
const DB_MAX = 0;
16+
17+
// Mel scale constants: filter banks span 20 Hz to Nyquist
18+
const MEL_MIN_VAL = 2595 * Math.log10(1 + 20 / 700);
19+
const MEL_MAX_VAL = 2595 * Math.log10(1 + (SAMPLE_RATE / 2) / 700);
20+
21+
function melToHz(mel) {
22+
return 700 * (Math.pow(10, mel / 2595) - 1);
23+
}
24+
function hzToMelBand(hz) {
25+
const mel = 2595 * Math.log10(1 + hz / 700);
26+
return ((mel - MEL_MIN_VAL) / (MEL_MAX_VAL - MEL_MIN_VAL)) * (N_MELS - 1);
27+
}
28+
29+
// --- Deterministic PRNG (LCG) ---------------------------------------------
30+
function makeLcg(seed) {
31+
let s = seed >>> 0;
32+
return () => {
33+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
34+
return s / 4294967296;
35+
};
36+
}
37+
38+
// --- Synthetic speech-like mel-spectrogram (voiced + unvoiced segments) ---
39+
function generateSpec() {
40+
const rand = makeLcg(42);
41+
const data = new Float32Array(N_MELS * N_FRAMES);
42+
43+
for (let f = 0; f < N_FRAMES; f++) {
44+
const tNorm = f / N_FRAMES;
45+
const isVoiced = Math.sin(tNorm * Math.PI * 7) > 0.1;
46+
const formant1 = 15 + 5 * Math.sin(tNorm * 2 * Math.PI * 1.5);
47+
const formant2 = 32 + 4 * Math.sin(tNorm * 2 * Math.PI * 2.3);
48+
49+
for (let m = 0; m < N_MELS; m++) {
50+
let db = -68 + rand() * 10;
51+
52+
if (isVoiced) {
53+
// Fundamental frequency harmonics
54+
for (let h = 1; h <= 7; h++) {
55+
const hBand = 7 * h;
56+
if (hBand < N_MELS) {
57+
db += (22 - h * 2.5) * Math.exp(-((m - hBand) ** 2) / 8);
58+
}
59+
}
60+
// Formant resonances (F1, F2)
61+
db += 20 * Math.exp(-((m - formant1) ** 2) / 18);
62+
db += 14 * Math.exp(-((m - formant2) ** 2) / 30);
63+
} else {
64+
// Unvoiced fricative: broadband energy in upper mel bands
65+
db += 10 * Math.exp(-((m - 52) ** 2) / 45) * (0.4 + rand() * 0.6);
66+
}
67+
68+
data[m * N_FRAMES + f] = Math.max(DB_MIN, Math.min(DB_MAX, db));
69+
}
70+
}
71+
return data;
72+
}
73+
74+
const specData = generateSpec();
75+
76+
// --- Color mapping: imprint_seq (pageBg → seq[0] → seq[1]) ---------------
77+
// Pre-parse hex stops once so dbToColor avoids repeated string parsing in the pixel loop
78+
const _p = (h) => [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];
79+
const BG = _p(t.pageBg), C0 = _p(t.seq[0]), C1 = _p(t.seq[1]);
80+
function dbToColor(db) {
81+
const n = (db - DB_MIN) / (DB_MAX - DB_MIN);
82+
let r, g, b, s;
83+
if (n < 0.4) {
84+
s = n / 0.4;
85+
r = BG[0] + s*(C0[0]-BG[0]); g = BG[1] + s*(C0[1]-BG[1]); b = BG[2] + s*(C0[2]-BG[2]);
86+
} else {
87+
s = (n - 0.4) / 0.6;
88+
r = C0[0] + s*(C1[0]-C0[0]); g = C0[1] + s*(C1[1]-C0[1]); b = C0[2] + s*(C1[2]-C0[2]);
89+
}
90+
return [Math.round(r), Math.round(g), Math.round(b)];
91+
}
92+
93+
// --- Custom plugin: spectrogram raster + colorbar -------------------------
94+
const spectrogramPlugin = {
95+
id: 'spectrogram',
96+
afterDraw(chart) {
97+
const ctx = chart.ctx;
98+
const { left, top, right, bottom } = chart.chartArea;
99+
const W = Math.floor(right - left);
100+
const H = Math.floor(bottom - top);
101+
102+
// Render spectrogram pixels to an offscreen canvas
103+
const off = document.createElement('canvas');
104+
off.width = W;
105+
off.height = H;
106+
const offCtx = off.getContext('2d');
107+
const imgData = offCtx.createImageData(W, H);
108+
const px = imgData.data;
109+
110+
for (let py = 0; py < H; py++) {
111+
const mFrac = (1 - py / (H - 1)) * (N_MELS - 1);
112+
const m0 = Math.floor(mFrac);
113+
const m1 = Math.min(m0 + 1, N_MELS - 1);
114+
const dm = mFrac - m0;
115+
116+
for (let pxX = 0; pxX < W; pxX++) {
117+
const fFrac = (pxX / (W - 1)) * (N_FRAMES - 1);
118+
const f0 = Math.floor(fFrac);
119+
const f1 = Math.min(f0 + 1, N_FRAMES - 1);
120+
const df = fFrac - f0;
121+
122+
// Bilinear interpolation for smooth rendering
123+
const db =
124+
specData[m0 * N_FRAMES + f0] * (1 - dm) * (1 - df) +
125+
specData[m0 * N_FRAMES + f1] * (1 - dm) * df +
126+
specData[m1 * N_FRAMES + f0] * dm * (1 - df) +
127+
specData[m1 * N_FRAMES + f1] * dm * df;
128+
129+
const [r, g, b] = dbToColor(db);
130+
const i = (py * W + pxX) * 4;
131+
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;
132+
}
133+
}
134+
offCtx.putImageData(imgData, 0, 0);
135+
136+
// Blit spectrogram into chart area with clip guard
137+
ctx.save();
138+
ctx.beginPath();
139+
ctx.rect(left, top, W, H);
140+
ctx.clip();
141+
ctx.drawImage(off, left, top);
142+
ctx.restore();
143+
144+
// Redraw chart border over spectrogram — thicker for visual weight
145+
ctx.strokeStyle = t.ink;
146+
ctx.lineWidth = 2;
147+
ctx.strokeRect(left, top, W, H);
148+
149+
// --- Colorbar ---------------------------------------------------------
150+
const cbX = right + 20;
151+
const cbW = 24;
152+
const cbH = H;
153+
const fs = Math.max(11, Math.round(H / 36));
154+
155+
// Elevated background separating the colorbar column from the plot
156+
ctx.fillStyle = t.elevatedBg;
157+
ctx.fillRect(right + 8, top - 6, 108, cbH + 12);
158+
159+
const grad = ctx.createLinearGradient(0, top, 0, top + cbH);
160+
grad.addColorStop(0, t.seq[1]);
161+
grad.addColorStop(0.6, t.seq[0]);
162+
grad.addColorStop(1, t.pageBg);
163+
ctx.fillStyle = grad;
164+
ctx.fillRect(cbX, top, cbW, cbH);
165+
ctx.strokeStyle = t.inkSoft;
166+
ctx.lineWidth = 1;
167+
ctx.strokeRect(cbX, top, cbW, cbH);
168+
169+
// Colorbar tick marks and dB labels
170+
ctx.fillStyle = t.inkSoft;
171+
ctx.font = `${fs}px sans-serif`;
172+
ctx.textAlign = 'left';
173+
const dbLabels = [0, -20, -40, -60, -80];
174+
for (const db of dbLabels) {
175+
const yPos = top + ((DB_MAX - db) / (DB_MAX - DB_MIN)) * cbH;
176+
ctx.beginPath();
177+
ctx.moveTo(cbX + cbW, yPos);
178+
ctx.lineTo(cbX + cbW + 5, yPos);
179+
ctx.strokeStyle = t.inkSoft;
180+
ctx.lineWidth = 1;
181+
ctx.stroke();
182+
ctx.fillText(`${db}`, cbX + cbW + 7, yPos + fs * 0.35);
183+
}
184+
185+
// Rotated "Power (dB)" label
186+
ctx.fillStyle = t.ink;
187+
ctx.font = `bold ${fs}px sans-serif`;
188+
ctx.save();
189+
ctx.translate(cbX + cbW + fs * 5.5, top + cbH / 2);
190+
ctx.rotate(-Math.PI / 2);
191+
ctx.textAlign = 'center';
192+
ctx.fillText('Power (dB)', 0, 0);
193+
ctx.restore();
194+
},
195+
};
196+
197+
// --- Title ----------------------------------------------------------------
198+
const titleText = 'spectrogram-mel · javascript · chartjs · anyplot.ai';
199+
const titleSize = 26;
200+
201+
// --- Mount ----------------------------------------------------------------
202+
const canvas = document.createElement('canvas');
203+
document.getElementById('container').appendChild(canvas);
204+
205+
// --- Chart ----------------------------------------------------------------
206+
new Chart(canvas, {
207+
type: 'scatter',
208+
data: { datasets: [{ data: [] }] },
209+
plugins: [spectrogramPlugin],
210+
options: {
211+
responsive: true,
212+
maintainAspectRatio: false,
213+
animation: false,
214+
layout: {
215+
padding: { right: 110, top: 10, bottom: 10, left: 10 },
216+
},
217+
plugins: {
218+
title: {
219+
display: true,
220+
text: titleText,
221+
color: t.ink,
222+
font: { size: titleSize, weight: '500' },
223+
padding: { bottom: 14 },
224+
},
225+
legend: { display: false },
226+
},
227+
scales: {
228+
x: {
229+
type: 'linear',
230+
min: 0,
231+
max: DURATION,
232+
ticks: {
233+
color: t.inkSoft,
234+
font: { size: 14 },
235+
callback: (v) => v.toFixed(1) + 's',
236+
maxTicksLimit: 7,
237+
},
238+
grid: { display: false },
239+
title: {
240+
display: true,
241+
text: 'Time (s)',
242+
color: t.ink,
243+
font: { size: 16 },
244+
},
245+
border: { color: t.inkSoft },
246+
},
247+
y: {
248+
type: 'linear',
249+
min: 0,
250+
max: N_MELS - 1,
251+
ticks: {
252+
color: t.inkSoft,
253+
font: { size: 14 },
254+
maxTicksLimit: 7,
255+
callback: (v) => {
256+
const mel = MEL_MIN_VAL + (v / (N_MELS - 1)) * (MEL_MAX_VAL - MEL_MIN_VAL);
257+
const hz = Math.round(melToHz(mel));
258+
return hz >= 1000 ? (Math.round(hz / 100) / 10) + 'k' : `${hz}`;
259+
},
260+
},
261+
grid: { display: false },
262+
title: {
263+
display: true,
264+
text: 'Frequency (Hz, mel scale)',
265+
color: t.ink,
266+
font: { size: 16 },
267+
},
268+
border: { color: t.inkSoft },
269+
},
270+
},
271+
},
272+
});

0 commit comments

Comments
 (0)