A sound level meter that lives in ProdDash rather than beside it. Takes an audio input the way the Timers module's LTC reader does, and offers four tiles: the level as a big number, per-channel bars with peak hold, an RTA, and a scrolling spectrogram.
This exists because Smaart turned out to be a dead end. Its JSON API is the client-window control channel and carries no SPL at all, in any edition, and REW's API does carry it but its licence forbids redistributing a copy inside our app. Both routes are written up in the memory note.
One measurement, many views
The capture child measures once and sends everything anyone might ask for — all four averagings and 1/48-octave bands, every frame. Each tile folds that down to the view it was configured for. No tile setting reaches the child, so two dashboards can watch the same room through different settings without disturbing the measurement or each other.
Banding from 1/3 to 1/48 octave is an exact power-domain sum, because every banding offered divides 48. Nothing is resampled.
Verified, not asserted
spl-capture --selftest prints the A and C weighting curves against the IEC 61672 table rather than asking to be believed:
48 kHz
96 kHz
Worst A error
3.14 dB
0.59 dB
31.5 Hz to 4 kHz
within 0.15 dB
within 0.13 dB
The 48 kHz figure is entirely the 16 kHz point, where the bilinear transform runs out of room even with the pole pair prewarped.
Cross-checked against REW on the same microphone: after removing the fixed calibration offset the two agree to a standard deviation of 0.5 dB on live room noise.
Running on AB-ProPresenter now, calibrated, reading about 50 dBA from the RTA mic on Dante channel 15.
Worth a reviewer's attention
The DSP is in Swift, not Node or the browser. LTC ships raw samples upstream because its decode is cheap and single-channel; sound level is filtering and FFTs over every channel at the device rate. Accelerate ships with macOS, so this costs nothing in dependencies and builds in two seconds.
The missing-binary fallback must never block. It was a spawnSync copied from the LTC listener, and on AB it froze the entire dashboard for nearly two minutes and then retried. It is an async spawn now. The binary should already be there regardless — built beside the app, or copied from any Mac of the same architecture.
Uncalibrated is said out loud. Every tile reads dBFS and labels it until an offset is entered, so a relative number can never be mistaken for sound pressure.
Settings from earlier versions map forward, so a layout already on a wall does not start reading dashes after a field rename.
Brought to interface rules 5 to 11 in the last two commits, which landed on main after this branch started.
A sound level meter that lives in ProdDash rather than beside it. Takes an audio input the way the Timers module's LTC reader does, and offers four tiles: the level as a big number, per-channel bars with peak hold, an RTA, and a scrolling spectrogram.
This exists because Smaart turned out to be a dead end. Its JSON API is the client-window control channel and carries no SPL at all, in any edition, and REW's API does carry it but its licence forbids redistributing a copy inside our app. Both routes are written up in the memory note.
## One measurement, many views
The capture child measures once and sends everything anyone might ask for — all four averagings and 1/48-octave bands, every frame. Each tile folds that down to the view it was configured for. No tile setting reaches the child, so two dashboards can watch the same room through different settings without disturbing the measurement or each other.
Banding from 1/3 to 1/48 octave is an exact power-domain sum, because every banding offered divides 48. Nothing is resampled.
## Verified, not asserted
`spl-capture --selftest` prints the A and C weighting curves against the IEC 61672 table rather than asking to be believed:
| | 48 kHz | 96 kHz |
|---|---|---|
| Worst A error | 3.14 dB | 0.59 dB |
| 31.5 Hz to 4 kHz | within 0.15 dB | within 0.13 dB |
The 48 kHz figure is entirely the 16 kHz point, where the bilinear transform runs out of room even with the pole pair prewarped.
Cross-checked against REW on the same microphone: after removing the fixed calibration offset the two agree to a standard deviation of **0.5 dB** on live room noise.
Running on AB-ProPresenter now, calibrated, reading about 50 dBA from the RTA mic on Dante channel 15.
## Worth a reviewer's attention
- **The DSP is in Swift, not Node or the browser.** LTC ships raw samples upstream because its decode is cheap and single-channel; sound level is filtering and FFTs over every channel at the device rate. Accelerate ships with macOS, so this costs nothing in dependencies and builds in two seconds.
- **The missing-binary fallback must never block.** It was a `spawnSync` copied from the LTC listener, and on AB it froze the entire dashboard for nearly two minutes and then retried. It is an async spawn now. The binary should already be there regardless — built beside the app, or copied from any Mac of the same architecture.
- **Uncalibrated is said out loud.** Every tile reads dBFS and labels it until an offset is entered, so a relative number can never be mistaken for sound pressure.
- **Settings from earlier versions map forward**, so a layout already on a wall does not start reading dashes after a field rename.
Brought to interface rules 5 to 11 in the last two commits, which landed on main after this branch started.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
michael
added 6 commits 2026-09-17 16:00:58 -04:00
A sound level meter that lives in ProdDash rather than beside it. Takes an
audio input the way the Timers module's LTC reader does, and offers four
tiles: the level as a big number, per-channel bars with peak hold, a
third-octave RTA, and a scrolling spectrogram.
The measurement happens in spl-capture.swift, not in Node and not in the
browser. LTC gets away with piping raw samples upstream because the decode
is cheap and single-channel; sound level is filtering and FFTs over every
channel at the device's rate, so the child does the work and only the
answers cross the pipe. That costs nothing in dependencies — Accelerate
ships with macOS, and the whole thing builds in two seconds with a plain
swiftc, exactly like ltc-capture.
The weighting filters are the part worth checking rather than trusting, so
`spl-capture --selftest` prints them against the IEC 61672 table. A- and
C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at
1 kHz. The top octave is where the bilinear transform runs out of room: at
48 kHz the 16 kHz point sits about 3 dB low even with the pole pair
prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a
broadband number and a real limit all the same, which is why the selftest
prints both rates instead of a pass mark.
Cross-checked against REW on the same microphone: after removing the fixed
calibration offset the two agree to a standard deviation of 0.5 dB on live
room noise.
Uncalibrated is a state the module says out loud — every tile reads dBFS
and labels it, so nobody mistakes a relative number for sound pressure. The
/measure route averages the calibrator tone in the power domain and hands
back the offset to paste into settings; a module cannot write its own admin
config, so that last step stays manual on purpose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The missing-binary fallback was a spawnSync, copied from the LTC listener
where the source is a third the size and builds in a few seconds. On the
production Mac this file took nearly two minutes, and spawnSync held the
Node event loop for every second of it: one module's optional helper froze
the entire dashboard, every other module with it, and then did it again
thirty seconds later on retry.
The build is now a plain spawn that reports "building" and calls start()
itself when it lands, so nothing waits on it. A failed build says so once
instead of spinning, and a build that never finishes is given up on after
five minutes.
The real lesson is the one in the comment: the binary should already be
there. It is built beside the app, or copied from any Mac of the same
architecture. Building on the machine that might be running a service is
the fallback, not the plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every control asked for here is a view choice, so none of them may reach
the capture child — two dashboards watching the same room must be able to
disagree about how to draw it without disturbing the measurement or each
other. That set the shape of the change: the child now sends everything
anyone might ask for, once, and each tile folds it down.
Averaging (was "Reading") is Fast, Slow, Inf and None, and the child
computes and sends all four every frame. Fast and Slow are the IEC time
constants, Inf is Leq since reset, None is the block with no time constant
at all. The admin-wide time constant is gone — it was never a property of
the room.
Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and
because every banding offered divides 48 a coarser band is an exact sum of
finer ones in the power domain. Nothing is resampled and no tile's choice
is visible to another. The transform grew to 16384 points to have bins fine
enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than
one bin, which the Banding help says out loud rather than pretending.
Each tile kind now carries its own settings, which is the fix for the
complaint that started this: the RTA was offering an alarm threshold and a
metric picker that did nothing to it. An entry's instanceSchema replaces the
module's, so a gear popover only holds controls that change that tile.
Also: frequency labels in three tiers that thin out as a tile narrows
instead of smearing, gridlines off by default on both graphs, RTA smoothing
as a damping factor on the drawn value only, a title-bar toggle to swap the
spectrogram's axes, and the threshold blurb removed.
Settings saved by 0.1.0 are mapped forward — a layout on a wall should not
start reading dashes because a field was renamed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The number was capped at 6rem, so a readout given half a wall stopped
growing at the size it reached in a small tile.
Removing the cap was not enough on its own. Sizing text off container units
means choosing a coefficient for some assumed number of characters, and the
value here is not a fixed width: the coefficient that fills a tile nicely
for "-45.9" pushes "-100.5" nine percent out through the side. Measured it
rather than guessed — the tile now sets a trial size, reads the width the
actual text wants, and scales to fit. That runs only when the tile size or
the length of the reading changes, not on every frame.
It now fills about 98% of the width wherever width is the limit and 92% of
the height wherever height is, at every tile size from 180x70 up to
1400x700, with no overflow on either axis. The CSS keeps a conservative
size for the first paint before the measurement has run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Deploying 0.3.0 to AB crashed the ProdDash server on restart:
TypeError: Cannot read properties of null (reading 'streams')
at server.js:273 req.on('close', () => state.streams.delete(res))
teardown() nulls `state` and then ends every open stream, which fires each
request's close handler — and the handler reached back through the `state`
it had just discarded. Any re-init with a browser connected would do it, so
saving admin config while a dashboard was open was enough to stop the whole
server, every module with it.
The stream handler now closes over this mount's Set rather than the module's
state: after teardown it deletes from a set nobody reads, which is harmless.
The same hazard existed anywhere a callback could outlive the mount — the
keepalive ping, a frame arriving from the listener mid-teardown, and the
/measure interval — and each is closed over or guarded now.
Reproduced first (open an SSE client, save config, watch it fall over) and
confirmed fixed against the same sequence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
michael
merged commit 48d0f5a04a into main2026-09-17 16:18:17 -04:00
michael
deleted branch claude/spl-meter-module2026-09-17 16:18:23 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A sound level meter that lives in ProdDash rather than beside it. Takes an audio input the way the Timers module's LTC reader does, and offers four tiles: the level as a big number, per-channel bars with peak hold, an RTA, and a scrolling spectrogram.
This exists because Smaart turned out to be a dead end. Its JSON API is the client-window control channel and carries no SPL at all, in any edition, and REW's API does carry it but its licence forbids redistributing a copy inside our app. Both routes are written up in the memory note.
One measurement, many views
The capture child measures once and sends everything anyone might ask for — all four averagings and 1/48-octave bands, every frame. Each tile folds that down to the view it was configured for. No tile setting reaches the child, so two dashboards can watch the same room through different settings without disturbing the measurement or each other.
Banding from 1/3 to 1/48 octave is an exact power-domain sum, because every banding offered divides 48. Nothing is resampled.
Verified, not asserted
spl-capture --selftestprints the A and C weighting curves against the IEC 61672 table rather than asking to be believed:The 48 kHz figure is entirely the 16 kHz point, where the bilinear transform runs out of room even with the pole pair prewarped.
Cross-checked against REW on the same microphone: after removing the fixed calibration offset the two agree to a standard deviation of 0.5 dB on live room noise.
Running on AB-ProPresenter now, calibrated, reading about 50 dBA from the RTA mic on Dante channel 15.
Worth a reviewer's attention
spawnSynccopied from the LTC listener, and on AB it froze the entire dashboard for nearly two minutes and then retried. It is an async spawn now. The binary should already be there regardless — built beside the app, or copied from any Mac of the same architecture.Brought to interface rules 5 to 11 in the last two commits, which landed on main after this branch started.
🤖 Generated with Claude Code
Deploying 0.3.0 to AB crashed the ProdDash server on restart: TypeError: Cannot read properties of null (reading 'streams') at server.js:273 req.on('close', () => state.streams.delete(res)) teardown() nulls `state` and then ends every open stream, which fires each request's close handler — and the handler reached back through the `state` it had just discarded. Any re-init with a browser connected would do it, so saving admin config while a dashboard was open was enough to stop the whole server, every module with it. The stream handler now closes over this mount's Set rather than the module's state: after teardown it deletes from a set nobody reads, which is harmless. The same hazard existed anywhere a callback could outlive the mount — the keepalive ping, a frame arriving from the listener mid-teardown, and the /measure interval — and each is closed over or guarded now. Reproduced first (open an SSE client, save config, watch it fall over) and confirmed fixed against the same sequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>