/ proof-of-concept / steelsim21
Sign in
proof-of-concept / steelsim21
steelsim21 / src / audio / spectrogram.ts
233 lines · 9.5 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")!;
0b5e420Spectrogram: integer layout sizes (fractional devicePixelRatio broke drawing)Jeremy Magland 113 // Whole pixels throughout: a fractional device pixel ratio would otherwise
114 // make the ImageData and the pixel buffer disagree in size.
115 const left = Math.round(52 * dpr), bottom = Math.round(22 * dpr), top = Math.round(6 * dpr), right = Math.round(8 * dpr);
116 const pw = Math.max(1, Math.floor(W - left - right)), ph = Math.max(1, Math.floor(H - top - bottom));
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 117 ctx.fillStyle = "#1c1e22";
118 ctx.fillRect(0, 0, W, H);
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 119 const { fmax, logFrequency } = this.opts;
120 const fmin = logFrequency ? 20 : 0;
121 const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
122 if (imageKey !== this.imageKey || !this.image) {
123 this.image = this.renderImage(ctx, pw, ph, fs, duration);
124 this.imageKey = imageKey;
125 }
126 ctx.putImageData(this.image, left, top);
127 this.drawAxes(ctx, dpr, left, top, pw, ph, fmin, fmax, logMin, logMax, logFrequency, duration);
128 if (playhead !== null && playhead >= 0 && playhead <= duration) {
129 const x = left + (playhead / duration) * pw;
130 ctx.fillStyle = "rgba(255,255,255,0.8)";
131 ctx.fillRect(x, top, Math.max(1, dpr), ph);
132 }
133 }
135 private renderImage(ctx: CanvasRenderingContext2D, pw: number, ph: number, fs: number, duration: number): ImageData {
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 136 const img = ctx.createImageData(pw, ph);
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 137 img.data.set(spectrogramPixels(this.columns, this.peakDb, this.opts, fs, duration, pw, ph));
139 }
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 141 /** STFT columns (dB) and their peak, for renderers outside the browser. */
142 get data(): { columns: Float32Array[]; peakDb: number } {
143 return { columns: this.columns, peakDb: this.peakDb };
144 }
e6ed3adRender lazily: no GPU or spectrogram work when nothing changedJeremy Magland 146 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 147 ctx.fillStyle = "#c8cbd0";
148 ctx.font = `${11 * dpr}px system-ui, sans-serif`;
149 ctx.textAlign = "right";
150 ctx.textBaseline = "middle";
151 const fticks = logFrequency ? [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000].filter((f) => f >= fmin && f <= fmax) : niceTicks(0, fmax, 6);
152 for (const f of fticks) {
153 const frac = logFrequency ? (Math.log(f) - logMin) / (logMax - logMin) : (f - fmin) / (fmax - fmin);
154 const y = top + (1 - frac) * ph;
155 ctx.fillText(f >= 1000 ? `${f / 1000}k` : `${f}`, left - 4 * dpr, y);
156 ctx.fillRect(left - 3 * dpr, y, 3 * dpr, 1);
157 }
158 ctx.textAlign = "center";
159 ctx.textBaseline = "top";
160 for (const t of niceTicks(0, duration, 8)) {
161 const x = left + (t / duration) * pw;
162 ctx.fillText(`${+t.toFixed(2)}`, x, top + ph + 4 * dpr);
163 ctx.fillRect(x, top + ph, 1, 3 * dpr);
164 }
165 ctx.save();
166 ctx.translate(12 * dpr, top + ph / 2);
167 ctx.rotate(-Math.PI / 2);
168 ctx.textBaseline = "middle";
169 ctx.fillText("Hz", 0, 0);
170 ctx.restore();
171 }
c8442c5Command-line renderer (Node WebGPU via Dawn) and gallery generatorJeremy Magland 174/** RGBA pixels (row-major, top row first) of the spectrogram plot area. */
175export function spectrogramPixels(columns: Float32Array[], peakDb: number, opts: SpectrogramOptions, fs: number, duration: number, pw: number, ph: number, peakRef?: number): Uint8ClampedArray {
176 const { windowSize, hop, fmax, dbFloor, logFrequency } = opts;
177 const binHz = fs / windowSize;
178 const fmin = logFrequency ? 20 : 0;
179 const data = new Uint8ClampedArray(pw * ph * 4);
180 const cols = columns.length;
181 const colPerPx = (duration * fs) / hop / pw;
182 const logMin = Math.log(fmin || 1), logMax = Math.log(fmax);
183 const ref = peakRef ?? peakDb;
184 for (let py = 0; py < ph; py++) {
185 const frac = 1 - py / ph;
186 const f = logFrequency ? Math.exp(logMin + frac * (logMax - logMin)) : fmin + frac * (fmax - fmin);
187 const bin = Math.min(windowSize / 2 - 1, Math.max(0, Math.round(f / binHz)));
188 for (let px = 0; px < pw; px++) {
189 const c0 = Math.floor(px * colPerPx);
190 const c1 = Math.max(c0 + 1, Math.floor((px + 1) * colPerPx));
191 let v = -Infinity;
192 for (let c = c0; c < c1 && c < cols; c++) v = Math.max(v, columns[c][bin]);
193 const o = (py * pw + px) * 4;
194 if (c0 >= cols) {
195 data[o] = 28; data[o + 1] = 30; data[o + 2] = 34; data[o + 3] = 255;
196 continue;
197 }
198 const t = ref > -200 ? Math.max(0, Math.min(1, (v - ref + dbFloor) / dbFloor)) : 0;
199 const [r, g, b] = inferno(t);
200 data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = 255;
201 }
202 }
203 return data;
f8a8a6cNonlinear steel sheet gong simulator with WebGPUJeremy Magland 206function niceTicks(lo: number, hi: number, count: number): number[] {
207 const span = hi - lo;
208 if (span <= 0) return [lo];
209 const raw = span / count;
210 const mag = 10 ** Math.floor(Math.log10(raw));
211 const norm = raw / mag;
212 const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
213 const ticks: number[] = [];
214 for (let t = Math.ceil(lo / step) * step; t <= hi + 1e-9; t += step) ticks.push(t);
215 return ticks;
218// Compact polynomial approximation of the inferno colormap.
219function inferno(t: number): [number, number, number] {
220 const c0 = [0.0002189403691192265, 0.001651004631001012, -0.01948089843709184];
221 const c1 = [0.1065134194856116, 0.5639564367884091, 3.932712388889277];
222 const c2 = [11.60249308247187, -3.972853965665698, -15.9423940062914];
223 const c3 = [-41.70399613139459, 17.43639888205313, 44.35414519872813];
224 const c4 = [77.162935699427, -33.40235894210092, -81.80730925738993];
225 const c5 = [-71.31942824499214, 32.62606426397723, 73.20951985803202];
226 const c6 = [25.13112622477341, -12.24266895238567, -23.07032500287172];
227 const out: number[] = [];
228 for (let k = 0; k < 3; k++) {
229 const v = c0[k] + t * (c1[k] + t * (c2[k] + t * (c3[k] + t * (c4[k] + t * (c5[k] + t * c6[k])))));
230 out.push(Math.max(0, Math.min(255, Math.round(v * 255))));
231 }
232 return [out[0], out[1], out[2]];
↑↓ move↵openescclose