SPL Meter 0.3.0: bring the tiles to the interface rules

Rebasing onto main landed rules 5 to 11, which this module predates and
broke in three places.

Rule 5 — no explanatory text in a tile's gear: the Banding and Smoothing
fields each carried a paragraph. The Banding one said something true and
worth knowing, that below roughly 200 Hz the window length limits real
resolution however fine the setting, so it moves to the source where the
band plan is built rather than being lost. Option labels shortened with it.

Rule 11 — one control per action: the spectrogram offered its axis both as
a gear field and as a title-bar button. The button stays, since that is
what was asked for, and the field is gone. The setting still persists with
the tile; saveInstanceSettings keeps keys that no schema declares.

Rule 9 — title-bar controls are icons: the axis button was drawing a "⇆"
as a label. It is an inline SVG now, with the tooltip carrying the words
and naming the current direction rather than the action.

Rule 8 also asks that a tile can switch off what it draws, so the readout
gained a Caption toggle and both graphs gained Labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-17 16:00:17 -04:00
co-authored by Claude Opus 5
parent 543795f9fd
commit 44fd0b10ba
3 changed files with 35 additions and 36 deletions
+21 -13
View File
@@ -210,7 +210,7 @@ function readoutView(root, moduleApi) {
*/
function fit() {
const w = root.clientWidth, h = root.clientHeight;
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}`;
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
if (key === fitKey || w < 2 || h < 2) return;
fitKey = key;
@@ -257,6 +257,7 @@ function readoutView(root, moduleApi) {
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
if (ch.clip) parts.push('CLIP');
if (!payload.calibrated) parts.push('uncalibrated');
sub.hidden = s.caption === false;
sub.textContent = parts.join(' · ');
fit();
},
@@ -331,18 +332,22 @@ function spectrumView(root, moduleApi, scrolling) {
}
if (scrolling) {
// A title-bar toggle so the axis can be flipped while watching, without
// opening the gear. It writes the same setting the gear does, so the two
// never disagree — but saveInstanceSettings deliberately does not remount
// us, so the new value is held here too and read back by axis().
// The axis lives here and nowhere else (rule 11): a tile does not offer
// the same action twice. Icon only, tooltip carries the words (rule 9).
// saveInstanceSettings deliberately does not remount us, so the new
// value is held here as well and read back by axis().
const swapIcon = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
+ '<path d="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
axisBtn = moduleApi.header.addButton({
label: '⇆',
title: 'Swap axes',
icon: swapIcon,
title: describe(),
onClick() {
axisOverride = axis() === 'x' ? 'y' : 'x';
moduleApi.saveInstanceSettings({ axis: axisOverride });
painted = false; // the picture means nothing in the other orientation
axisBtn.title = axis() === 'x' ? 'Time left to right' : 'Time top to bottom';
axisBtn.title = describe();
},
});
}
@@ -403,7 +408,8 @@ function spectrumView(root, moduleApi, scrolling) {
const freqs = folded.freq;
let values = folded.db;
const grid = s.gridlines === true;
const axisPad = 13;
const showLabels = s.labels !== false;
const axisPad = showLabels || grid ? 13 : 0;
if (!scrolling) {
// Damp the bars toward the measurement. Cosmetic: `values` is what
@@ -450,15 +456,17 @@ function spectrumView(root, moduleApi, scrolling) {
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
}
if (showLabels) {
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
return;
}
// ── spectrogram ──
const timeX = axis() === 'x';
const colW = 2;
const gutter = grid ? axisPad : 0;
const gutter = showLabels ? axisPad : 0;
// Frequency occupies whichever axis time does not.
const freqExtent = timeX ? h - gutter : w - gutter;
const timeExtent = timeX ? w : h;
@@ -489,7 +497,7 @@ function spectrumView(root, moduleApi, scrolling) {
}
}
if (grid) {
if (grid || showLabels) {
const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
@@ -499,7 +507,7 @@ function spectrumView(root, moduleApi, scrolling) {
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h);
ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.strokeStyle = border; ctx.globalAlpha = 0.35; ctx.beginPath();
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
for (const l of labels) {
if (timeX) {
const y = Math.round(l.pos) + 0.5;
@@ -511,7 +519,7 @@ function spectrumView(root, moduleApi, scrolling) {
}
ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center';
for (const l of labels) {
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 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",
"name": "SPL Meter",
"version": "0.2.1",
"version": "0.3.0",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+11 -20
View File
@@ -48,10 +48,10 @@ function averagingField() {
return {
type: 'select', label: 'Averaging', default: 'fast',
options: [
{ value: 'fast', label: 'Fast — 125 ms' },
{ value: 'slow', label: 'Slow — 1 s' },
{ value: 'inf', label: 'Inf — since reset' },
{ value: 'none', label: 'None — no averaging' },
{ value: 'fast', label: 'Fast · 125 ms' },
{ value: 'slow', label: 'Slow · 1 s' },
{ value: 'inf', label: 'Inf · since reset' },
{ value: 'none', label: 'None' },
],
};
}
@@ -63,9 +63,6 @@ function averagingField() {
*/
const BANDING_FIELD = {
type: 'select', label: 'Banding', default: '3',
help: 'Finer bandings need a wide tile to be worth drawing, and below '
+ 'roughly 200 Hz the window length limits real resolution however fine '
+ 'this is set.',
options: [
{ value: '3', label: '1/3 octave' },
{ value: '6', label: '1/6 octave' },
@@ -193,11 +190,12 @@ module.exports = {
type: 'select', label: 'Show', default: 'level',
options: [
{ value: 'level', label: 'Level' },
{ value: 'leq1m', label: 'Leq, last minute' },
{ value: 'leq10m', label: 'Leq, last 10 minutes' },
{ value: 'peakC', label: 'Peak, C-weighted' },
{ value: 'leq1m', label: 'Leq 1 min' },
{ value: 'leq10m', label: 'Leq 10 min' },
{ value: 'peakC', label: 'Peak C' },
],
},
caption: { type: 'switch', label: 'Caption', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
@@ -222,15 +220,14 @@ module.exports = {
banding: BANDING_FIELD,
smoothing: {
type: 'select', label: 'Smoothing', default: 'medium',
help: 'How hard the bars are damped between frames. Cosmetic only — '
+ 'it changes the animation, never the measurement.',
options: [
{ value: 'off', label: 'Off — every frame as measured' },
{ value: 'off', label: 'Off' },
{ value: 'light', label: 'Light' },
{ value: 'medium', label: 'Medium' },
{ value: 'heavy', label: 'Heavy' },
],
},
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
@@ -240,13 +237,7 @@ module.exports = {
instanceSchema: {
channel: CHANNEL_FIELD,
banding: BANDING_FIELD,
axis: {
type: 'select', label: 'Time runs', default: 'x',
options: [
{ value: 'x', label: 'Left to right' },
{ value: 'y', label: 'Top to bottom' },
],
},
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},