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:
co-authored by
Claude Opus 5
parent
c464e5b4ca
commit
c8b51c6c2f
+85
-37
@@ -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,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": [
|
||||||
|
|||||||
Reference in New Issue
Block a user