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;
6const 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]];
8function color(t: number): [number, number, number] {
9 const x = Math.min(Math.max(t, 0), 1);
10 for (let i = 1; i < STOPS.length; i++) {
11 if (x <= STOPS[i][0]) {
12 const [t0, r0, g0, b0] = STOPS[i - 1], [t1, r1, g1, b1] = STOPS[i], u = (x - t0) / (t1 - t0);
13 return [r0 + u * (r1 - r0), g0 + u * (g1 - g0), b0 + u * (b1 - b0)];
14 }
15 }
16 return [250, 240, 80];
17}
19export class SpectrogramView {
20 private readonly canvas: HTMLCanvasElement;
21 private readonly off = document.createElement("canvas");
22 private stft: Stft | null = null;
23 private duration = 1; private drawn = 0; private maxPower = 0;
24 private rowLo: Int32Array = new Int32Array(0); private rowHi: Int32Array = new Int32Array(0);
25 cursorTime: number | null = null; frontierTime = 0;
27 constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; }
29 reset(stft: Stft, duration: number) {
30 this.stft = stft; this.duration = duration; this.drawn = 0; this.maxPower = 0; this.frontierTime = 0; this.cursorTime = null;
31 this.off.width = Math.max(1, Math.ceil((duration * stft.fs) / stft.hop)); this.off.height = ROWS;
32 this.off.getContext("2d")!.fillStyle = "#0d0838"; this.off.getContext("2d")!.fillRect(0, 0, this.off.width, ROWS);
33 const fMax = stft.fs / 2;
34 this.rowLo = new Int32Array(ROWS); this.rowHi = new Int32Array(ROWS);
35 for (let r = 0; r < ROWS; r++) {
36 const fHi = F_MIN * Math.pow(fMax / F_MIN, 1 - r / ROWS), fLo = F_MIN * Math.pow(fMax / F_MIN, 1 - (r + 1) / ROWS);
37 this.rowLo[r] = Math.max(0, Math.floor(fLo / stft.binFrequency(1)));
38 this.rowHi[r] = Math.max(this.rowLo[r] + 1, Math.ceil(fHi / stft.binFrequency(1)));
39 }
40 this.draw();
41 }
43 /** Draw the columns computed since the last call; rescales everything when the maximum grows. */
44 update() {
45 const st = this.stft;
46 if (!st) return;
47 let mx = this.maxPower;
48 for (let c = this.drawn; c < st.columns.length; c++) for (const p of st.columns[c]) mx = Math.max(mx, p);
49 if (mx > this.maxPower * 2) { this.maxPower = mx; this.drawn = 0; } else this.maxPower = Math.max(this.maxPower, mx);
50 const ctx = this.off.getContext("2d")!;
51 const ref = this.maxPower || 1;
52 for (let c = this.drawn; c < st.columns.length && c < this.off.width; c++) {
53 const img = ctx.createImageData(1, ROWS), col = st.columns[c];
54 for (let r = 0; r < ROWS; r++) {
55 let p = 0;
56 for (let b = this.rowLo[r]; b < Math.min(this.rowHi[r], col.length); b++) p = Math.max(p, col[b]);
57 const db = 10 * Math.log10(p / ref + 1e-30);
58 const [R, G, B] = color(1 + db / DB_RANGE);
59 img.data[4 * r] = R; img.data[4 * r + 1] = G; img.data[4 * r + 2] = B; img.data[4 * r + 3] = 255;
60 }
61 ctx.putImageData(img, c, 0);
62 }
63 this.drawn = Math.min(st.columns.length, this.off.width);
64 this.draw();
65 }
67 draw() {
68 const cv = this.canvas, dpr = window.devicePixelRatio || 1;
69 const w = cv.clientWidth, h = cv.clientHeight;
70 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); }
71 const ctx = cv.getContext("2d")!;
72 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
73 ctx.imageSmoothingEnabled = false;
74 ctx.fillStyle = "#0d0838"; ctx.fillRect(0, 0, w, h);
75 const left = 44, bottom = 18, pw = w - left - 6, ph = h - bottom - 4;
76 if (this.stft) ctx.drawImage(this.off, 0, 0, this.off.width, ROWS, left, 4, pw, ph);
77 ctx.fillStyle = "#c8c8c8"; ctx.font = "10px system-ui, sans-serif"; ctx.textAlign = "right";
78 const fMax = this.stft ? this.stft.fs / 2 : 22050;
79 for (const f of [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) {
80 if (f < F_MIN || f > fMax) continue;
81 const y = 4 + ph * (1 - Math.log(f / F_MIN) / Math.log(fMax / F_MIN));
82 ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), left - 4, y + 3);
83 ctx.fillStyle = "rgba(255,255,255,0.12)"; ctx.fillRect(left, y, pw, 1); ctx.fillStyle = "#c8c8c8";
84 }
85 ctx.textAlign = "center";
86 const tick = this.duration > 4 ? 1 : this.duration > 1.5 ? 0.5 : 0.1;
87 for (let t = 0; t <= this.duration + 1e-9; t += tick) {
88 const x = left + (pw * t) / this.duration;
89 ctx.fillText(`${t.toFixed(tick < 0.5 ? 1 : 1)}`, x, h - 5);
90 }
91 const xf = left + (pw * this.frontierTime) / this.duration;
92 ctx.fillStyle = "rgba(255,255,255,0.5)"; ctx.fillRect(xf, 4, 1, ph);
93 if (this.cursorTime !== null) { ctx.fillStyle = "#ff5a5a"; ctx.fillRect(left + (pw * this.cursorTime) / this.duration, 4, 2, ph); }
94 }
95}