/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
steelsim21 / src / audio / spectrogram.ts
231 lines · 9.3 KBCodeBlameHistory
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 1// Incremental STFT spectrogram drawn on a 2D canvas.
3import { FFT } from "./fft.ts";
5export interface SpectrogramOptions {
6 windowSize: number;
7 hop: number;
8 fmax: number; // Hz
9 dbFloor: number; // dB below the peak
10 logFrequency: boolean;
13export class Spectrogram {
14 private fft: FFT;
15 private window: Float32Array;
16 private re: Float32Array;
17 private im: Float32Array;
18 /** Magnitude (dB) columns, each of length windowSize / 2. */
19 private columns: Float32Array[] = [];
20 private consumed = 0;
21 private peakDb = -Infinity;
22 private opts: SpectrogramOptions;
24 constructor(opts: SpectrogramOptions) {
25 this.opts = opts;
26 this.fft = new FFT(opts.windowSize);
27 this.window = new Float32Array(opts.windowSize);
28 for (let k = 0; k < opts.windowSize; k++) this.window[k] = 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / opts.windowSize);
29 this.re = new Float32Array(opts.windowSize);
30 this.im = new Float32Array(opts.windowSize);
31 }
33 get options(): SpectrogramOptions {
34 return this.opts;
35 }
37 /** Changes options; recomputes from scratch when the STFT parameters change. */
38 configure(opts: Partial<SpectrogramOptions>, audio: Float32Array, nSamples: number): void {
39 const next = { ...this.opts, ...opts };
40 const recompute = next.windowSize !== this.opts.windowSize || next.hop !== this.opts.hop;
41 this.opts = next;
42 if (recompute) {
43 this.fft = new FFT(next.windowSize);
44 this.window = new Float32Array(next.windowSize);
45 for (let k = 0; k < next.windowSize; k++) this.window[k] = 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / next.windowSize);
46 this.re = new Float32Array(next.windowSize);
47 this.im = new Float32Array(next.windowSize);
48 this.reset();
49 this.update(audio, nSamples);
50 }
51 }
53 reset(): void {
54 this.columns = [];
55 this.consumed = 0;
56 this.peakDb = -Infinity;
57 }
59 /** Adds columns for all complete windows in audio[0..nSamples). */
60 update(audio: Float32Array, nSamples: number): void {
61 const { windowSize, hop } = this.opts;
62 while (this.consumed + windowSize <= nSamples) {
63 for (let k = 0; k < windowSize; k++) {
64 this.re[k] = audio[this.consumed + k] * this.window[k];
65 this.im[k] = 0;
66 }
67 this.fft.transform(this.re, this.im);
68 const col = new Float32Array(windowSize / 2);
69 for (let k = 0; k < windowSize / 2; k++) {
70 const mag2 = this.re[k] * this.re[k] + this.im[k] * this.im[k];
71 const db = 10 * Math.log10(mag2 + 1e-30);
72 col[k] = db;
73 if (db > this.peakDb) this.peakDb = db;
74 }
75 this.columns.push(col);
76 this.consumed += hop;
77 }
78 }
80 get columnCount(): number {
81 return this.columns.length;
82 }
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 84 private imageKey = "";
85 private image: ImageData | null = null;
86 private drawKey = "";
88 /** Forces the next draw to recompute everything. */
89 invalidate(): void {
90 this.imageKey = "";
91 this.drawKey = "";
92 }
94 /**
95 * Draws the spectrogram; `duration` [s] fixes the time axis, `playhead` [s] draws a cursor.
96 * The plot image is cached and only recomputed when the data, options, or size change,
97 * and nothing is drawn at all when the previous frame is still current.
98 */
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 99 draw(canvas: HTMLCanvasElement, fs: number, duration: number, playhead: number | null): void {
100 const dpr = window.devicePixelRatio || 1;
101 const W = Math.max(1, Math.floor(canvas.clientWidth * dpr));
102 const H = Math.max(1, Math.floor(canvas.clientHeight * dpr));
104 const imageKey = `${W}|${H}|${this.columns.length}|${duration}|${fs}|${o.windowSize}|${o.hop}|${o.fmax}|${o.dbFloor}|${o.logFrequency}`;
105 const drawKey = `${imageKey}|${playhead === null ? "-" : playhead.toFixed(4)}`;
106 if (drawKey === this.drawKey) return;
107 this.drawKey = drawKey;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 108 if (canvas.width !== W || canvas.height !== H) {
109 canvas.width = W;
110 canvas.height = H;
111 }
112 const ctx = canvas.getContext("2d")!;
113 const left = 52 * dpr, bottom = 22 * dpr, top = 6 * dpr, right = 8 * dpr;
114 const pw = Math.max(1, W - left - right), ph = Math.max(1, H - top - bottom);
115 ctx.fillStyle = "#1c1e22";
116 ctx.fillRect(0, 0, W, H);
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 117 const { fmax, logFrequency } = this.opts;
118 const fmin = logFrequency ? 20 : 0;
119 const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
120 if (imageKey !== this.imageKey || !this.image) {
121 this.image = this.renderImage(ctx, pw, ph, fs, duration);
122 this.imageKey = imageKey;
123 }
124 ctx.putImageData(this.image, left, top);
125 this.drawAxes(ctx, dpr, left, top, pw, ph, fmin, fmax, logMin, logMax, logFrequency, duration);
126 if (playhead !== null && playhead >= 0 && playhead <= duration) {
127 const x = left + (playhead / duration) * pw;
128 ctx.fillStyle = "rgba(255,255,255,0.8)";
129 ctx.fillRect(x, top, Math.max(1, dpr), ph);
130 }
131 }
133 private renderImage(ctx: CanvasRenderingContext2D, pw: number, ph: number, fs: number, duration: number): ImageData {
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 134 const img = ctx.createImageData(pw, ph);
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 135 img.data.set(spectrogramPixels(this.columns, this.peakDb, this.opts, fs, duration, pw, ph));
137 }
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 139 /** STFT columns (dB) and their peak, for renderers outside the browser. */
140 get data(): { columns: Float32Array[]; peakDb: number } {
141 return { columns: this.columns, peakDb: this.peakDb };
142 }
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 144 private drawAxes(ctx: CanvasRenderingContext2D, dpr: number, left: number, top: number, pw: number, ph: number, fmin: number, fmax: number, logMin: number, logMax: number, logFrequency: boolean, duration: number): void {
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 145 ctx.fillStyle = "#c8cbd0";
146 ctx.font = `${11 * dpr}px system-ui, sans-serif`;
147 ctx.textAlign = "right";
148 ctx.textBaseline = "middle";
149 const fticks = logFrequency ? [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000].filter((f) => f >= fmin && f <= fmax) : niceTicks(0, fmax, 6);
150 for (const f of fticks) {
151 const frac = logFrequency ? (Math.log(f) - logMin) / (logMax - logMin) : (f - fmin) / (fmax - fmin);
152 const y = top + (1 - frac) * ph;
153 ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, left - 4 * dpr, y);
154 ctx.fillRect(left - 3 * dpr, y, 3 * dpr, 1);
155 }
156 ctx.textAlign = "center";
157 ctx.textBaseline = "top";
158 for (const t of niceTicks(0, duration, 8)) {
159 const x = left + (t / duration) * pw;
160 ctx.fillText(`${+t.toFixed(2)}`, x, top + ph + 4 * dpr);
161 ctx.fillRect(x, top + ph, 1, 3 * dpr);
162 }
163 ctx.save();
164 ctx.translate(12 * dpr, top + ph / 2);
165 ctx.rotate(-Math.PI / 2);
166 ctx.textBaseline = "middle";
167 ctx.fillText("Hz", 0, 0);
168 ctx.restore();
169 }
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 172/** RGBA pixels (row-major, top row first) of the spectrogram plot area. */
173export function spectrogramPixels(columns: Float32Array[], peakDb: number, opts: SpectrogramOptions, fs: number, duration: number, pw: number, ph: number, peakRef?: number): Uint8ClampedArray {
174 const { windowSize, hop, fmax, dbFloor, logFrequency } = opts;
175 const binHz = fs / windowSize;
176 const fmin = logFrequency ? 20 : 0;
177 const data = new Uint8ClampedArray(pw * ph * 4);
178 const cols = columns.length;
179 const colPerPx = (duration * fs) / hop / pw;
180 const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
181 const ref = peakRef ?? peakDb;
182 for (let py = 0; py < ph; py++) {
183 const frac = 1 - py / ph;
184 const f = logFrequency ? Math.exp(logMin + frac * (logMax - logMin)) : fmin + frac * (fmax - fmin);
185 const bin = Math.min(windowSize / 2 - 1, Math.max(0, Math.round(f / binHz)));
186 for (let px = 0; px < pw; px++) {
187 const c0 = Math.floor(px * colPerPx);
188 const c1 = Math.max(c0 + 1, Math.floor((px + 1) * colPerPx));
189 let v = -Infinity;
190 for (let c = c0; c < c1 && c < cols; c++) v = Math.max(v, columns[c][bin]);
191 const o = (py * pw + px) * 4;
192 if (c0 >= cols) {
193 data[o] = 28; data[o + 1] = 30; data[o + 2] = 34; data[o + 3] = 255;
194 continue;
195 }
196 const t = ref > -200 ? Math.max(0, Math.min(1, (v - ref + dbFloor) / dbFloor)) : 0;
197 const [r, g, b] = inferno(t);
198 data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = 255;
199 }
200 }
201 return data;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 204function niceTicks(lo: number, hi: number, count: number): number[] {
205 const span = hi - lo;
206 if (span <= 0) return [lo];
207 const raw = span / count;
208 const mag = 10 ** Math.floor(Math.log10(raw));
209 const norm = raw / mag;
210 const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
211 const ticks: number[] = [];
212 for (let t = Math.ceil(lo / step) * step; t <= hi + 1e-9; t += step) ticks.push(t);
213 return ticks;
216// Compact polynomial approximation of the inferno colormap.
217function inferno(t: number): [number, number, number] {
218 const c0 = [0.0002189403691192265, 0.001651004631001012, -0.01948089843709184];
219 const c1 = [0.1065134194856116, 0.5639564367884091, 3.932712388889277];
220 const c2 = [11.60249308247187, -3.972853965665698, -15.9423940062914];
221 const c3 = [-41.70399613139459, 17.43639888205313, 44.35414519872813];
222 const c4 = [77.162935699427, -33.40235894210092, -81.80730925738993];
223 const c5 = [-71.31942824499214, 32.62606426397723, 73.20951985803202];
224 const c6 = [25.13112622477341, -12.24266895238567, -23.07032500287172];
225 const out: number[] = [];
226 for (let k = 0; k < 3; k++) {
227 const v = c0[k] + t * (c1[k] + t * (c2[k] + t * (c3[k] + t * (c4[k] + t * (c5[k] + t * c6[k])))));
228 out.push(Math.max(0, Math.min(255, Math.round(v * 255))));
229 }
230 return [out[0], out[1], out[2]];
↑↓ move↵openescclose