From 9313c9e42a1d6a92cf634ececebf734c77b2785d Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 23 Sep 2026 15:25:43 -0400 Subject: [PATCH] SPL Meter 0.4.2: the device picker can build the tool it needs NA could not list a single audio interface. The module was installed from the catalog, which ships source only because the capture binary is gitignored, and the picker shells out to that binary with --list. So it returned nothing, forever: [spl-meter] device list failed: spawn .../spl-capture ENOENT The missing-binary build existed, but only inside the listener, and the listener only starts once a device has been picked. No tool meant no device list, and no device meant no listener to build the tool. Nothing was going to break that on its own. The build now lives at module scope and both callers use it: init() asks for it on mount whether or not a device is configured, and /devices asks again each time it is called, so opening the admin page is enough to start it. It is still a plain spawn that never touches the event loop, and health() now names the situation instead of leaving an empty dropdown unexplained. Also removed two per-listener build flags left shadowing the module-scope ones, which would have made the "is a build already running" test always false and let a second build start under the first. Tested the way it actually failed: a data directory holding only the six source files, no binary. The build starts on mount and /devices answers with the real device list moments later. Co-Authored-By: Claude Opus 5 --- modules/spl-meter/module.json | 2 +- modules/spl-meter/server.js | 24 +++++- modules/spl-meter/spl-listener.js | 134 +++++++++++++++++------------- 3 files changed, 100 insertions(+), 60 deletions(-) diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 2e07058..529f394 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.4.1", + "version": "0.4.2", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index a735d03..0ae966a 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -20,7 +20,7 @@ */ const path = require('path'); -const { createSplListener } = require('./spl-listener'); +const { createSplListener, ensureCaptureBinary } = require('./spl-listener'); /** Reference level of a sound level calibrator. Effectively always 94 dB. */ const CALIBRATOR_DB = 94; @@ -114,11 +114,19 @@ module.exports = { error: '', streams: new Set(), silentSince: null, + toolMissing: '', listener: null, lastSent: '', pinger: null, }; + // Before anything else: the device picker shells out to this tool, so a + // machine that installed the module from the catalog (source only — the + // binary is gitignored) needs the build kicked off here. Waiting for a + // listener would never happen: no tool means no device list, and no + // device means no listener. + state.toolMissing = ensureCaptureBinary({ moduleDir, log }); + if (!config.enabled) { state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) }; @@ -177,6 +185,9 @@ module.exports = { return { stop() { teardown(); }, health() { + if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) { + return { status: 'error', message: state.toolMissing }; + } if (state.error) return { status: 'error', message: state.error }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; const quiet = silentMs(); @@ -300,6 +311,13 @@ module.exports = { /** Device list for the admin page. Runs the capture tool's --list, which is the only thing that knows what CoreAudio can see. */ 'GET /devices': async (req, res) => { + const missing = ensureCaptureBinary({ moduleDir: __dirname, log }); + if (missing) { + if (state) state.toolMissing = missing; + log(`device list unavailable — ${missing}`); + json(res, 200, { options: [] }); + return; + } try { const devices = await listDevices(); json(res, 200, { @@ -438,6 +456,10 @@ function listDevices() { }); } +function fsExists(p) { + try { return require('fs').existsSync(p); } catch { return false; } +} + function json(res, code, body) { const text = JSON.stringify(body); res.writeHead(code, { diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js index 1a9381b..15f1221 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -17,7 +17,8 @@ * module's ltc-capture. It normally arrives already built — compiled beside * the app, or copied from any Mac of the same architecture. When it is * 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. + * loop, and from module scope so the device picker can trigger it too — see + * ensureCaptureBinary for what both of those cost the first time round. * * 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 @@ -30,6 +31,74 @@ const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); +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'; +const BUILDING = 'Building the capture tool — this takes a minute or two'; + +/** + * Build the capture tool if it is missing, WITHOUT blocking, and shared by + * everything that needs it. + * + * This lives at module scope rather than inside a listener because of a trap + * the first version walked into: the admin page's device picker runs the tool + * with --list, but the build only ran when a listener started, and a listener + * only starts once a device has been picked. On a machine that got the module + * from the catalog — source only, the binary being gitignored — the picker + * was empty, and nothing would ever fill it. + * + * It also must never block. It was a spawnSync once, copied from the LTC + * listener where the source is a third the size and builds in seconds. Here + * it took nearly two minutes on the production Mac and held the Node event + * loop for all of it, freezing every other module, then did it again on the + * next retry. + * + * The binary should normally already be there: built beside the app, or + * copied from any Mac of the same architecture. This is the fallback. + * + * @returns {string} '' when the tool is ready, otherwise what to say. + */ +let building = false; +let buildFailed = false; + +function ensureCaptureBinary({ moduleDir, log, onReady }) { + const bin = path.join(moduleDir, 'spl-capture'); + if (fs.existsSync(bin)) return ''; + const src = bin + '.swift'; + if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; + if (building) return BUILDING; + if (buildFailed) return BUILD_HELP; + + building = true; + log('spl-capture binary missing — building it in the background'); + const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] }); + let buildErr = ''; + build.stderr.setEncoding('utf8'); + 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 (typeof onReady === 'function') onReady(); + return; + } + buildFailed = true; + log(`spl-capture build failed (exit ${code})` + + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : '')); + }); + return BUILDING; +} + const RESTART_MS = 5000; /** An immediate exit is config-shaped (bad device, channel out of range) — retrying every 5 s would only fill the log with the same sentence. */ @@ -53,64 +122,13 @@ function createSplListener(opts) { let stallTimer = null; let startedAt = 0; let meta = null; - let building = false; - let buildFailed = false; 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. - */ + /** '' when the tool is ready, otherwise what to say about it. */ function ensureBinary() { - if (fs.existsSync(bin)) return ''; - const src = bin + '.swift'; - if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; - if (building) return 'Building the capture tool — this takes a minute or two'; - if (buildFailed) return BUILD_HELP; - - building = true; - log('spl-capture binary missing — building it in the background'); - const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] }); - let buildErr = ''; - build.stderr.setEncoding('utf8'); - 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'; + return ensureCaptureBinary({ moduleDir, log, onReady() { + if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } + } }); } function scheduleRestart(ms) { @@ -137,7 +155,7 @@ function createSplListener(opts) { onUpdate({ error: missing }); // 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); + if (missing !== BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS); return; } @@ -236,4 +254,4 @@ function createSplListener(opts) { }; } -module.exports = { createSplListener }; +module.exports = { createSplListener, ensureCaptureBinary };