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
Showing only changes of commit e90ae51495 - Show all commits
+60 -14
View File
@@ -14,9 +14,10 @@
* { error: '<what is wrong and how to fix it>' } not measuring * { error: '<what is wrong and how to fix it>' } not measuring
* *
* The capture binary is per-machine and gitignored, exactly like the Timers * The capture binary is per-machine and gitignored, exactly like the Timers
* module's ltc-capture: if it is missing this tries one swiftc build of the * module's ltc-capture. It normally arrives already built — compiled beside
* source next to it, and otherwise says how to build it elsewhere and copy * the app, or copied from any Mac of the same architecture. When it is
* it over. * missing this will build it, but in the background and never on the event
* loop: see ensureBinary for what that cost the first time it was wrong.
* *
* The same macOS gotcha applies as for LTC, and is worth repeating because * The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a * it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -27,7 +28,7 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn, spawnSync } = require('child_process'); const { spawn } = require('child_process');
const RESTART_MS = 5000; const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) — /** An immediate exit is config-shaped (bad device, channel out of range) —
@@ -52,21 +53,64 @@ function createSplListener(opts) {
let stallTimer = null; let stallTimer = null;
let startedAt = 0; let startedAt = 0;
let meta = null; let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0; let lastFrameWall = 0;
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
/**
* Build the capture tool if it is missing, WITHOUT blocking.
*
* This used to be a spawnSync, copied from the LTC listener where the
* source is a third the size and builds in a few seconds. Here it took
* nearly two minutes on the production Mac and froze the entire ProdDash
* server for every second of it — one module's optional helper took the
* whole dashboard down, then did it again on every retry. Nothing that
* runs while a service might be on is allowed to hold the event loop.
*
* The build is a fallback for a module installed from the catalog on a
* machine with a Swift toolchain. The normal path is a binary built
* alongside the app, or copied from any same-architecture Mac.
*
* @returns {string} '' when the binary is ready, otherwise what to say.
*/
function ensureBinary() { function ensureBinary() {
if (fs.existsSync(bin)) return ''; if (fs.existsSync(bin)) return '';
const src = bin + '.swift'; const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
log('spl-capture binary missing — trying a one-time swiftc build'); if (building) return 'Building the capture tool — this takes a minute or two';
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 180000 }); if (buildFailed) return BUILD_HELP;
if (built.status === 0 && fs.existsSync(bin)) {
log('spl-capture compiled'); building = true;
return ''; log('spl-capture binary missing — building it in the background');
} const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
return 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' let buildErr = '';
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` ' build.stderr.setEncoding('utf8');
+ 'and copy it here'; build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return 'Building the capture tool — this takes a minute or two';
} }
function scheduleRestart(ms) { function scheduleRestart(ms) {
@@ -91,7 +135,9 @@ function createSplListener(opts) {
const missing = ensureBinary(); const missing = ensureBinary();
if (missing) { if (missing) {
onUpdate({ error: missing }); onUpdate({ error: missing });
scheduleRestart(BAD_CONFIG_RETRY_MS); // A build in flight calls start() itself when it lands; anything else
// is config-shaped and should back off hard rather than spin.
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }