4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 1import { stft, type Stft } from './fft';
2import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
4function hexToRgb(h: string): [number, number, number] {
5 const v = parseInt(h.replace('#', ''), 16);
6 return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
7}
f89ae61Widget: spectrogram of the sound (light entry loaded when near the viewport), fmax attribute and choice; axes and spectrogram read colors at their canvasJeremy Magland 9// Sequential ramp read from a CSS custom property (as seen by el): comma-separated hex steps.
10export function rampLUT(varName: string, el?: Element): Uint8ClampedArray {
11 const stops = css(varName, el).split(',').map((s) => hexToRgb(s.trim()));
4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 12 const lut = new Uint8ClampedArray(256 * 3);
13 for (let i = 0; i < 256; i++) {
14 const t = (i / 255) * (stops.length - 1);
15 const k = Math.min(stops.length - 2, Math.floor(t));
16 const f = t - k;
17 for (let c = 0; c < 3; c++) lut[3 * i + c] = stops[k][c] + f * (stops[k + 1][c] - stops[k][c]);
18 }
19 return lut;
20}
22export class Spectrogram {
23 private s: Stft | null = null;
24 fmax = 5000;
25 range = 80;
26 private tip: HTMLDivElement;
28 constructor(private canvas: HTMLCanvasElement) {
29 this.tip = document.createElement('div');
30 this.tip.className = 'tooltip';
31 this.tip.hidden = true;
32 canvas.parentElement!.append(this.tip);
33 canvas.addEventListener('mousemove', (e) => this.hover(e.offsetX, e.offsetY));
34 canvas.addEventListener('mouseleave', () => (this.tip.hidden = true));
35 new ResizeObserver(() => this.draw()).observe(canvas);
36 }
38 setSignal(x: Float32Array, fs: number) {
39 this.s = stft(x, fs, 2048, Math.max(64, Math.floor(x.length / 1200)));
40 this.draw();
41 }
43 draw() {
44 const { ctx, w, h } = setupCanvas(this.canvas);
45 ctx.clearRect(0, 0, w, h);
46 const s = this.s;
47 if (!s) return;
48 const fmax = Math.min(this.fmax, s.fs / 2);
49 const nb = Math.max(2, Math.round((fmax / s.fs) * s.nfft));
50 const nf = s.mags.length;
51 let top = -Infinity;
52 for (const m of s.mags) for (let k = 0; k <= nb; k++) top = Math.max(top, m[k]);
f89ae61Widget: spectrogram of the sound (light entry loaded when near the viewport), fmax attribute and choice; axes and spectrogram read colors at their canvasJeremy Magland 53 const lut = rampLUT('--ramp', this.canvas);
4a8abcfString of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewerJeremy Magland 54 const img = new ImageData(nf, nb);
55 for (let j = 0; j < nf; j++) {
56 const m = s.mags[j];
57 for (let k = 0; k < nb; k++) {
58 const v = Math.max(0, Math.min(1, (m[k] - top + this.range) / this.range));
59 const li = Math.round(v * 255) * 3;
60 const p = 4 * ((nb - 1 - k) * nf + j);
61 img.data[p] = lut[li];
62 img.data[p + 1] = lut[li + 1];
63 img.data[p + 2] = lut[li + 2];
64 img.data[p + 3] = 255;
65 }
66 }
67 const off = document.createElement('canvas');
68 off.width = nf;
69 off.height = nb;
70 off.getContext('2d')!.putImageData(img, 0, 0);
71 const duration = ((nf - 1) * s.hop) / s.fs;
72 ctx.imageSmoothingEnabled = true;
73 ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
74 drawAxes(ctx, w, h, [0, duration], [0, fmax], { xLabel: 'time (s)', yLabel: 'frequency (Hz)', grid: false });
75 }
77 private hover(px: number, py: number) {
78 const s = this.s;
79 if (!s) return;
80 const w = this.canvas.clientWidth;
81 const h = this.canvas.clientHeight;
82 if (px < PAD.l || px > w - PAD.r || py < PAD.t || py > h - PAD.b) {
83 this.tip.hidden = true;
84 return;
85 }
86 const fmax = Math.min(this.fmax, s.fs / 2);
87 const fx = (px - PAD.l) / (w - PAD.l - PAD.r);
88 const fy = 1 - (py - PAD.t) / (h - PAD.t - PAD.b);
89 const j = Math.min(s.mags.length - 1, Math.round(fx * (s.mags.length - 1)));
90 const f = fy * fmax;
91 const k = Math.round((f / s.fs) * s.nfft);
92 this.tip.textContent = `t: ${fmtNum((j * s.hop) / s.fs)} s\nf: ${fmtNum(f)} Hz\nlevel: ${s.mags[j][k].toFixed(1)} dB`;
93 this.tip.hidden = false;
94 this.tip.style.left = `${Math.min(px + 12, w - 140)}px`;
95 this.tip.style.top = `${PAD.t + 4}px`;
96 }
97}