SPL Meter: measure the room, not just watch it #10

Merged
michael merged 7 commits from claude/spl-meter-module into main 2026-09-17 16:18:17 -04:00
3 changed files with 35 additions and 36 deletions
Showing only changes of commit 44fd0b10ba - Show all commits
+21 -13
View File
@@ -210,7 +210,7 @@ function readoutView(root, moduleApi) {
*/ */
function fit() { function fit() {
const w = root.clientWidth, h = root.clientHeight; 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; if (key === fitKey || w < 2 || h < 2) return;
fitKey = key; fitKey = key;
@@ -257,6 +257,7 @@ function readoutView(root, moduleApi) {
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`]; const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
if (ch.clip) parts.push('CLIP'); if (ch.clip) parts.push('CLIP');
if (!payload.calibrated) parts.push('uncalibrated'); if (!payload.calibrated) parts.push('uncalibrated');
sub.hidden = s.caption === false;
sub.textContent = parts.join(' · '); sub.textContent = parts.join(' · ');
fit(); fit();
}, },
@@ -331,18 +332,22 @@ function spectrumView(root, moduleApi, scrolling) {
} }
if (scrolling) { if (scrolling) {
// A title-bar toggle so the axis can be flipped while watching, without // The axis lives here and nowhere else (rule 11): a tile does not offer
// opening the gear. It writes the same setting the gear does, so the two // the same action twice. Icon only, tooltip carries the words (rule 9).
// never disagree — but saveInstanceSettings deliberately does not remount // saveInstanceSettings deliberately does not remount us, so the new
// us, so the new value is held here too and read back by axis(). // 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({ axisBtn = moduleApi.header.addButton({
label: '⇆', icon: swapIcon,
title: 'Swap axes', title: describe(),
onClick() { onClick() {
axisOverride = axis() === 'x' ? 'y' : 'x'; axisOverride = axis() === 'x' ? 'y' : 'x';
moduleApi.saveInstanceSettings({ axis: axisOverride }); moduleApi.saveInstanceSettings({ axis: axisOverride });
painted = false; // the picture means nothing in the other orientation 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; const freqs = folded.freq;
let values = folded.db; let values = folded.db;
const grid = s.gridlines === true; const grid = s.gridlines === true;
const axisPad = 13; const showLabels = s.labels !== false;
const axisPad = showLabels || grid ? 13 : 0;
if (!scrolling) { if (!scrolling) {
// Damp the bars toward the measurement. Cosmetic: `values` is what // 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); 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'; 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); for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
return; return;
} }
// ── spectrogram ── // ── spectrogram ──
const timeX = axis() === 'x'; const timeX = axis() === 'x';
const colW = 2; const colW = 2;
const gutter = grid ? axisPad : 0; const gutter = showLabels ? axisPad : 0;
// Frequency occupies whichever axis time does not. // Frequency occupies whichever axis time does not.
const freqExtent = timeX ? h - gutter : w - gutter; const freqExtent = timeX ? h - gutter : w - gutter;
const timeExtent = timeX ? w : h; const timeExtent = timeX ? w : h;
@@ -489,7 +497,7 @@ function spectrumView(root, moduleApi, scrolling) {
} }
} }
if (grid) { if (grid || showLabels) {
const posOf = (f) => { const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]); const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent; 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); if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h); else ctx.fillRect(0, 0, gutter, h);
ctx.fillStyle = muted; ctx.font = '9px system-ui'; 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) { 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;
@@ -511,7 +519,7 @@ function spectrumView(root, moduleApi, scrolling) {
} }
ctx.stroke(); ctx.globalAlpha = 1; ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center'; 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)); 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); 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.2.1", "version": "0.3.0",
"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": [
+11 -20
View File
@@ -48,10 +48,10 @@ function averagingField() {
return { return {
type: 'select', label: 'Averaging', default: 'fast', type: 'select', label: 'Averaging', default: 'fast',
options: [ options: [
{ value: 'fast', label: 'Fast — 125 ms' }, { value: 'fast', label: 'Fast · 125 ms' },
{ value: 'slow', label: 'Slow — 1 s' }, { value: 'slow', label: 'Slow · 1 s' },
{ value: 'inf', label: 'Inf — since reset' }, { value: 'inf', label: 'Inf · since reset' },
{ value: 'none', label: 'None — no averaging' }, { value: 'none', label: 'None' },
], ],
}; };
} }
@@ -63,9 +63,6 @@ function averagingField() {
*/ */
const BANDING_FIELD = { const BANDING_FIELD = {
type: 'select', label: 'Banding', default: '3', 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: [ options: [
{ value: '3', label: '1/3 octave' }, { value: '3', label: '1/3 octave' },
{ value: '6', label: '1/6 octave' }, { value: '6', label: '1/6 octave' },
@@ -193,11 +190,12 @@ module.exports = {
type: 'select', label: 'Show', default: 'level', type: 'select', label: 'Show', default: 'level',
options: [ options: [
{ value: 'level', label: 'Level' }, { value: 'level', label: 'Level' },
{ value: 'leq1m', label: 'Leq, last minute' }, { value: 'leq1m', label: 'Leq 1 min' },
{ value: 'leq10m', label: 'Leq, last 10 minutes' }, { value: 'leq10m', label: 'Leq 10 min' },
{ value: 'peakC', label: 'Peak, C-weighted' }, { value: 'peakC', label: 'Peak C' },
], ],
}, },
caption: { type: 'switch', label: 'Caption', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 }, warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 }, alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
}, },
@@ -222,15 +220,14 @@ module.exports = {
banding: BANDING_FIELD, banding: BANDING_FIELD,
smoothing: { smoothing: {
type: 'select', label: 'Smoothing', default: 'medium', 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: [ options: [
{ value: 'off', label: 'Off — every frame as measured' }, { value: 'off', label: 'Off' },
{ value: 'light', label: 'Light' }, { value: 'light', label: 'Light' },
{ value: 'medium', label: 'Medium' }, { value: 'medium', label: 'Medium' },
{ value: 'heavy', label: 'Heavy' }, { value: 'heavy', label: 'Heavy' },
], ],
}, },
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false }, gridlines: { type: 'switch', label: 'Gridlines', default: false },
}, },
}, },
@@ -240,13 +237,7 @@ module.exports = {
instanceSchema: { instanceSchema: {
channel: CHANNEL_FIELD, channel: CHANNEL_FIELD,
banding: BANDING_FIELD, banding: BANDING_FIELD,
axis: { labels: { type: 'switch', label: 'Labels', default: true },
type: 'select', label: 'Time runs', default: 'x',
options: [
{ value: 'x', label: 'Left to right' },
{ value: 'y', label: 'Top to bottom' },
],
},
gridlines: { type: 'switch', label: 'Gridlines', default: false }, gridlines: { type: 'switch', label: 'Gridlines', default: false },
}, },
}, },