1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 1// Spectrogram canvas: log-frequency rows, dB colour scale relative to the loudest column seen,
2// a time axis spanning the whole run, the frontier of the generated signal and a playback cursor.
3import type { Stft } from "../analysis/spectrogram.ts";
5const ROWS = 256, DB_RANGE = 80, F_MIN = 40;
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 6export type FrequencyScale = "linear" | "log";
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 7const STOPS = [[0.0, 13, 8, 60], [0.25, 40, 40, 130], [0.5, 30, 120, 140], [0.75, 90, 200, 90], [1.0, 250, 240, 80]];
9function color(t: number): [number, number, number] {
10 const x = Math.min(Math.max(t, 0), 1);
11 for (let i = 1; i < STOPS.length; i++) {
12 if (x <= STOPS[i][0]) {
13 const [t0, r0, g0, b0] = STOPS[i - 1], [t1, r1, g1, b1] = STOPS[i], u = (x - t0) / (t1 - t0);
14 return [r0 + u * (r1 - r0), g0 + u * (g1 - g0), b0 + u * (b1 - b0)];
15 }
16 }
17 return [250, 240, 80];
18}
20export class SpectrogramView {
21 private readonly canvas: HTMLCanvasElement;
22 private readonly off = document.createElement("canvas");
23 private stft: Stft | null = null;
24 private duration = 1; private drawn = 0; private maxPower = 0;
25 private rowLo: Int32Array = new Int32Array(0); private rowHi: Int32Array = new Int32Array(0);
26 cursorTime: number | null = null; frontierTime = 0;
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 27 private scale: FrequencyScale = "linear";
28 private fMaxShown = 0; // 0 = up to Nyquist
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 29
30 constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; }
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 32 /** Frequency axis; changing it redraws the stored columns. */
33 setScale(scale: FrequencyScale) {
34 if (scale === this.scale) return;
35 this.scale = scale;
36 if (this.stft) { this.buildRows(); this.drawn = 0; this.update(); } else this.draw();
37 }
38 get frequencyScale() { return this.scale; }
40 /** Upper edge of the axis in Hz; 0 restores the Nyquist frequency. */
41 setMaxFrequency(f: number) {
42 this.fMaxShown = f;
43 if (this.stft) { this.buildRows(); this.drawn = 0; this.update(); } else this.draw();
44 }
46 private get fMax() { return this.fMaxShown > 0 ? this.fMaxShown : this.stft ? this.stft.fs / 2 : 22050; }
47 private get fMin() { return this.scale === "log" ? F_MIN : 0; }
49 /** Frequency at the top edge of row r (r = 0 is the top of the image). */
50 private rowFrequency(r: number): number {
51 const u = 1 - r / ROWS;
52 return this.scale === "log" ? this.fMin * Math.pow(this.fMax / this.fMin, u) : this.fMax * u;
53 }
55 private buildRows() {
56 const st = this.stft!;
57 this.rowLo = new Int32Array(ROWS); this.rowHi = new Int32Array(ROWS);
58 const df = st.binFrequency(1);
59 for (let r = 0; r < ROWS; r++) {
60 const fHi = this.rowFrequency(r), fLo = this.rowFrequency(r + 1);
61 this.rowLo[r] = Math.max(0, Math.floor(fLo / df));
62 this.rowHi[r] = Math.max(this.rowLo[r] + 1, Math.ceil(fHi / df));
63 }
64 }
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 66 reset(stft: Stft, duration: number) {
67 this.stft = stft; this.duration = duration; this.drawn = 0; this.maxPower = 0; this.frontierTime = 0; this.cursorTime = null;
68 this.off.width = Math.max(1, Math.ceil((duration * stft.fs) / stft.hop)); this.off.height = ROWS;
69 this.off.getContext("2d")!.fillStyle = "#0d0838"; this.off.getContext("2d")!.fillRect(0, 0, this.off.width, ROWS);
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 70 this.buildRows();
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 71 this.draw();
72 }
74 /** Draw the columns computed since the last call; rescales everything when the maximum grows. */
75 update() {
76 const st = this.stft;
77 if (!st) return;
78 let mx = this.maxPower;
79 for (let c = this.drawn; c < st.columns.length; c++) for (const p of st.columns[c]) mx = Math.max(mx, p);
80 if (mx > this.maxPower * 2) { this.maxPower = mx; this.drawn = 0; } else this.maxPower = Math.max(this.maxPower, mx);
81 const ctx = this.off.getContext("2d")!;
82 const ref = this.maxPower || 1;
83 for (let c = this.drawn; c < st.columns.length && c < this.off.width; c++) {
84 const img = ctx.createImageData(1, ROWS), col = st.columns[c];
85 for (let r = 0; r < ROWS; r++) {
86 let p = 0;
87 for (let b = this.rowLo[r]; b < Math.min(this.rowHi[r], col.length); b++) p = Math.max(p, col[b]);
88 const db = 10 * Math.log10(p / ref + 1e-30);
89 const [R, G, B] = color(1 + db / DB_RANGE);
90 img.data[4 * r] = R; img.data[4 * r + 1] = G; img.data[4 * r + 2] = B; img.data[4 * r + 3] = 255;
91 }
92 ctx.putImageData(img, c, 0);
93 }
94 this.drawn = Math.min(st.columns.length, this.off.width);
95 this.draw();
96 }
98 draw() {
99 const cv = this.canvas, dpr = window.devicePixelRatio || 1;
100 const w = cv.clientWidth, h = cv.clientHeight;
101 if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
102 const ctx = cv.getContext("2d")!;
103 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
104 ctx.imageSmoothingEnabled = false;
105 ctx.fillStyle = "#0d0838"; ctx.fillRect(0, 0, w, h);
106 const left = 44, bottom = 18, pw = w - left - 6, ph = h - bottom - 4;
107 if (this.stft) ctx.drawImage(this.off, 0, 0, this.off.width, ROWS, left, 4, pw, ph);
108 ctx.fillStyle = "#c8c8c8"; ctx.font = "10px system-ui, sans-serif"; ctx.textAlign = "right";
3534482Linear spectrogram axis by default; capture and replay the displacement movie at a chosen slowdownJeremy Magland 109 const fMax = this.fMax;
110 const ticks = this.scale === "log"
111 ? [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
112 : (() => {
113 const step = [500, 1000, 2000, 2500, 5000, 10000].find((s) => fMax / s <= 11) ?? 10000;
114 const out: number[] = [];
115 for (let f = 0; f <= fMax + 1e-6; f += step) out.push(f);
116 return out;
117 })();
118 for (const f of ticks) {
119 if (f > fMax || (this.scale === "log" && f < F_MIN)) continue;
120 const u = this.scale === "log" ? Math.log(f / this.fMin) / Math.log(fMax / this.fMin) : f / fMax;
121 const y = 4 + ph * (1 - u);
122 ctx.fillText(f === 0 ? "0" : f >= 1000 ? `${f / 1000}k` : String(f), left - 4, y + 3);
123 if (f > 0) { ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(left, y, pw, 1); ctx.fillStyle = "#c8c8c8"; }
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 124 }
125 ctx.textAlign = "center";
126 const tick = this.duration > 4 ? 1 : this.duration > 1.5 ? 0.5 : 0.1;
127 for (let t = 0; t <= this.duration + 1e-9; t += tick) {
128 const x = left + (pw * t) / this.duration;
129 ctx.fillText(`${t.toFixed(tick < 0.5 ? 1 : 1)}`, x, h - 5);
130 }
131 const xf = left + (pw * this.frontierTime) / this.duration;
132 ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(xf, 4, 1, ph);
133 if (this.cursorTime !== null) { ctx.fillStyle = "#ff5a5a"; ctx.fillRect(left + (pw * this.cursorTime) / this.duration, 4, 2, ph); }
134 }
135}