Spectrogram: stop dragging the labels along with the picture

The spectrogram scrolled by copying the visible canvas onto itself one step
along. That canvas already held the frequency labels and gridlines, because
they were drawn straight onto it, so every frame smeared them sideways and
left a trail. Arrived with the labels in 0.3.0; the gridlines did it too.

Only measured pixels belong on a surface that scrolls. The history now lives
on its own offscreen canvas that holds nothing but spectrum, and the visible
canvas is repainted from scratch each frame — history first, annotations
over the top. Nothing drawn as an annotation can reach the scroll buffer by
construction, rather than by remembering to clear the right strip.

The layout was muddled in the same place and is fixed with it: the gutter
used to be reserved along the bottom while the labels were drawn down the
left, overlapping the plot. Labels now sit beside the axis they describe —
down the left when frequency runs vertically, with room for "12.5k", and
along the bottom when it runs horizontally.

Verified both orientations by fingerprinting the gutter and the plot two and
a half seconds apart: gutter identical, plot changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-23 09:58:32 -04:00
co-authored by Claude Opus 5
parent c464e5b4ca
commit c8b51c6c2f
2 changed files with 86 additions and 38 deletions
+85 -37
View File
@@ -350,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>'; root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas'); const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false }); const ctx = canvas.getContext('2d', { alpha: false });
let painted = false; // spectrogram: has the background been laid down /**
* The spectrogram's scrolling history, on its own surface.
*
* It used to scroll by copying the visible canvas onto itself, which also
* copied whatever had been drawn on top of it — so every frame dragged the
* frequency labels and gridlines one step further along and left a smear.
* Only measured pixels belong on a surface that scrolls; annotations are
* drawn fresh over the top of it, every frame, and never into it.
*/
let history = null;
let historyCtx = null;
let painted = false; // spectrogram: has the history been cleared yet
let smoothed = null; // RTA: displayed values, damped toward the real ones let smoothed = null; // RTA: displayed values, damped toward the real ones
let smoothedFor = ''; let smoothedFor = '';
let axisBtn = null; let axisBtn = null;
@@ -497,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
// ── spectrogram ── // ── spectrogram ──
const timeX = axis() === 'x'; const timeX = axis() === 'x';
const colW = 2; const colW = 2;
const gutter = showLabels ? axisPad : 0; // A label along the bottom needs only its line height; one down the
// Frequency occupies whichever axis time does not. // side has to fit "12.5k" beside the plot rather than on top of it.
const freqExtent = timeX ? h - gutter : w - gutter; const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
const timeExtent = timeX ? w : h; // Labels belong beside the frequency axis: down the left when frequency
// runs vertically, along the bottom when it runs horizontally.
const plotX = timeX ? gutter : 0;
const plotW = timeX ? w - gutter : w;
const plotH = timeX ? h : h - gutter;
const freqExtent = timeX ? plotH : plotW;
const timeExtent = timeX ? plotW : plotH;
if (plotW < 4 || plotH < 4) return;
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; } // The history surface is sized to the plot, in device pixels, and is
// thrown away whenever that geometry changes — a scroll buffer means
// Source rect in device pixels, destination in CSS pixels: the context // nothing once it is being stretched into a different shape.
// carries a devicePixelRatio transform but the canvas as an image const dpr = window.devicePixelRatio || 1;
// source does not, and letting drawImage infer them scales the picture const needW = Math.max(1, Math.round(plotW * dpr));
// up and pushes every column off the canvas. const needH = Math.max(1, Math.round(plotH * dpr));
if (timeX) { if (!history || history.width !== needW || history.height !== needH) {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); history = document.createElement('canvas');
} else { history.width = needW;
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h); history.height = needH;
historyCtx = history.getContext('2d', { alpha: false });
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
} }
if (!painted) {
historyCtx.fillStyle = bg;
historyCtx.fillRect(0, 0, plotW, plotH);
painted = true;
}
// Scroll the history, then add the newest column. The source rectangle
// is in device pixels and the destination in CSS pixels, because the
// context carries the dpr transform but the canvas as an image source
// does not.
historyCtx.drawImage(history, 0, 0, history.width, history.height,
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
const bandExtent = freqExtent / values.length; const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) { for (let i = 0; i < values.length; i++) {
ctx.fillStyle = heat(norm(values[i], payload.calibrated)); historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) { if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them. // Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent, historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1); colW, Math.ceil(bandExtent) + 1);
} else { } else {
// Low frequencies at the left. // Low frequencies at the left.
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW, historyCtx.fillRect(i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW); Math.ceil(bandExtent) + 1, colW);
} }
} }
if (grid || showLabels) { // Repaint the visible canvas from scratch every frame: history first,
const posOf = (f) => { // annotations over it. Nothing drawn here can ever reach the scroll.
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent; ctx.drawImage(history, 0, 0, history.width, history.height,
}; plotX, 0, plotW, plotH);
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
ctx.fillStyle = bg; if (!grid && !showLabels) return;
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h); const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
ctx.fillStyle = muted; ctx.font = '9px system-ui'; const posOf = (f) => {
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath(); const t = Math.log2(f / freqs[0]) / span;
return timeX ? plotH - t * plotH : plotX + t * plotW;
};
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
if (grid) {
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
ctx.beginPath();
for (const l of labels) { for (const l of labels) {
if (timeX) { if (timeX) {
const y = Math.round(l.pos) + 0.5; const y = Math.round(l.pos) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.moveTo(plotX, y); ctx.lineTo(w, y);
} else { } else {
const x = Math.round(l.pos) + 0.5; const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
} }
} }
ctx.stroke(); ctx.globalAlpha = 1; ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center'; }
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3)); if (showLabels) {
else ctx.fillText(hzLabel(l.f), l.pos, h - 3); ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.textAlign = timeX ? 'right' : 'center';
for (const l of labels) {
if (timeX) {
ctx.fillText(hzLabel(l.f), gutter - 2,
Math.max(7, Math.min(h - 2, l.pos + 3)));
} else {
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
} }
} }
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.4.0", "version": "0.4.1",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [