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 };