diff --git a/.gitignore b/.gitignore index b553dd6..7960cd0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,3 @@ launcher/macos/build/ launcher/macos/.cache/ launcher/windows/build/ launcher/windows/.cache/ - -# Compiled per-machine, source sits next to it (see spl-capture.swift). -modules/spl-meter/spl-capture diff --git a/modules/spl-meter/bin/spl-capture-macos-arm64 b/modules/spl-meter/bin/spl-capture-macos-arm64 new file mode 100755 index 0000000..2b9c11b Binary files /dev/null and b/modules/spl-meter/bin/spl-capture-macos-arm64 differ diff --git a/modules/spl-meter/bin/spl-capture-macos-x86_64 b/modules/spl-meter/bin/spl-capture-macos-x86_64 new file mode 100755 index 0000000..0ed93de Binary files /dev/null and b/modules/spl-meter/bin/spl-capture-macos-x86_64 differ diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 8eea9e5..0fb7c4d 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.3", + "version": "0.5.0", "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/native/build.sh b/modules/spl-meter/native/build.sh new file mode 100755 index 0000000..d6748b0 --- /dev/null +++ b/modules/spl-meter/native/build.sh @@ -0,0 +1,48 @@ +#!/bin/zsh +# Build this module's native helper for every macOS architecture it ships — +# ../bin/spl-capture-macos-arm64 and ../bin/spl-capture-macos-x86_64 — +# from spl-capture.swift beside this script. Run it on any Mac with the +# Xcode command line tools; both architectures cross-compile from either. +# The binaries are committed: a machine running ProdDash never builds +# anything (docs/MODULE-GUIDE.md, "Native helpers"). +# +# Signed with a Developer ID Application certificate when one is installed +# (override with CODESIGN_IDENTITY) — hardened runtime plus the audio-input +# entitlement, which a helper that reads a microphone needs under the +# hardened runtime — and ad-hoc otherwise, which is fine on your own Macs. +# +# Windows is not built here: there is no Windows implementation of this +# tool yet, so on Windows the module reports measurement off. +set -e -u +cd "$(dirname "$0")" + +NAME="spl-capture" +DEPLOYMENT_TARGET="13.0" + +command -v swiftc >/dev/null || { + echo "build.sh: swiftc not found — install the Xcode command line tools: xcode-select --install" >&2 + exit 1 +} + +IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}') +fi +if [[ -n "$IDENTITY" ]]; then + echo "signing as $IDENTITY" + SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp) +else + echo "signing ad-hoc (no Developer ID installed — fine on your own Macs)" + SIGN=(--force --sign - --options runtime) +fi + +mkdir -p ../bin +for ARCH in arm64 x86_64; do + OUT="../bin/$NAME-macos-$ARCH" + echo " compiling $OUT" + swiftc -O -whole-module-optimization -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$OUT" "$NAME.swift" + codesign "${SIGN[@]}" --entitlements helper.entitlements "$OUT" + codesign --verify --strict "$OUT" + echo " built $OUT ($(du -h "$OUT" | awk '{print $1}'))" +done diff --git a/modules/spl-meter/native/helper.entitlements b/modules/spl-meter/native/helper.entitlements new file mode 100644 index 0000000..6d11e71 --- /dev/null +++ b/modules/spl-meter/native/helper.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/modules/spl-meter/spl-capture.swift b/modules/spl-meter/native/spl-capture.swift similarity index 99% rename from modules/spl-meter/spl-capture.swift rename to modules/spl-meter/native/spl-capture.swift index 25a76dc..4b90d5b 100644 --- a/modules/spl-meter/spl-capture.swift +++ b/modules/spl-meter/native/spl-capture.swift @@ -8,10 +8,11 @@ // measurement is done here and only the answers cross the pipe. // // Everything below uses Accelerate, which ships with macOS. No third-party -// code, no package manager, ~2 s to build: +// code, no package manager. It ships prebuilt in the module's bin/ (one +// binary per platform, committed — a booth never builds anything). To +// rebuild after a change here, on any Mac with the Xcode command line tools: // -// swiftc -O -o modules/spl-meter/spl-capture \ -// modules/spl-meter/spl-capture.swift +// modules/spl-meter/native/build.sh (both architectures) // // spl-capture --list // every input-capable device: name, input channels, sample rate diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index 0ae966a..ded2dbc 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -20,7 +20,7 @@ */ const path = require('path'); -const { createSplListener, ensureCaptureBinary } = require('./spl-listener'); +const { createSplListener, captureTool } = require('./spl-listener'); /** Reference level of a sound level calibrator. Effectively always 94 dB. */ const CALIBRATOR_DB = 94; @@ -120,12 +120,9 @@ module.exports = { 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 }); + // The capture tool ships prebuilt in the module (bin/); with none for + // this platform every reading is off and the health line says so. + state.toolMissing = captureTool(moduleDir).missing; if (!config.enabled) { state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; @@ -185,9 +182,7 @@ module.exports = { return { stop() { teardown(); }, health() { - if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) { - return { status: 'error', message: state.toolMissing }; - } + if (state.toolMissing) 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(); @@ -311,11 +306,10 @@ 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: [] }); + const tool = captureTool(__dirname); + if (tool.missing) { + log(`device list unavailable — ${tool.missing}`); + json(res, 200, { options: [], error: tool.missing }); return; } try { @@ -447,7 +441,7 @@ function teardown() { function listDevices() { return new Promise((resolve, reject) => { const { execFile } = require('child_process'); - execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 }, + execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 }, (err, stdout) => { if (err && !stdout) { reject(err); return; } try { resolve(JSON.parse(stdout).devices || []); } @@ -456,10 +450,6 @@ 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 ebe1276..6738762 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -3,7 +3,8 @@ /** * Sound level listener — owns the native capture child. * - * Spawns spl-capture (compiled from spl-capture.swift beside it) for the + * Spawns spl-capture (bin/spl-capture-macos-, shipped prebuilt; + * source in native/) for the * admin-configured device and channels, reads its JSON lines, and hands the * latest frame upward. All measurement happens in the child; this file is * plumbing and supervision, nothing more. @@ -13,12 +14,11 @@ * { frame: {...}, meta: {...} } measuring * { error: '' } not measuring * - * The capture binary is per-machine and gitignored, exactly like the Timers - * 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, 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 capture tool ships prebuilt inside the module, one binary per + * platform under bin/ (module guide, "Native helpers"), exactly like the + * Timers module's ltc-capture: nothing is ever built on the machine running + * ProdDash. captureTool() picks the binary for this platform; when there is + * none, measurement reports itself off and the rest of the module carries on. * * 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 @@ -31,72 +31,31 @@ 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'; +/* ── the capture tool: prebuilt, one binary per platform ───────────────── */ + +/** What the module ships in bin/, by platform and architecture. */ +const HELPERS = { + darwin: { arm64: 'spl-capture-macos-arm64', x64: 'spl-capture-macos-x86_64' }, + win32: { x64: 'spl-capture-win-x64.exe' }, +}; /** - * Build the capture tool if it is missing, WITHOUT blocking, and shared by - * everything that needs it. + * The capture tool for this machine. It is never built here and never + * looked for anywhere else: the module ships it, or measurement is off on + * this platform and says so. `bin` is the usable binary; otherwise + * `missing` is what to tell the admin page and the tiles. * - * 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. + * @returns {{bin:string, missing:string}} */ -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; +function captureTool(moduleDir, platform = process.platform, arch = process.arch) { + const name = HELPERS[platform]?.[arch] || ''; + const bin = name ? path.join(moduleDir, 'bin', name) : ''; + if (bin && fs.existsSync(bin)) return { bin, missing: '' }; + const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`; + return { + bin: '', + missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`, + }; } const RESTART_MS = 5000; @@ -114,7 +73,6 @@ const STALL_MS = 3000; */ function createSplListener(opts) { const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts; - const bin = path.join(moduleDir, 'spl-capture'); let child = null; let stopped = false; @@ -124,13 +82,6 @@ function createSplListener(opts) { let meta = null; let lastFrameWall = 0; - /** '' when the tool is ready, otherwise what to say about it. */ - function ensureBinary() { - return ensureCaptureBinary({ moduleDir, log, onReady() { - if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } - } }); - } - function scheduleRestart(ms) { if (stopped || restartTimer) return; restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms); @@ -150,15 +101,11 @@ function createSplListener(opts) { function start() { if (stopped) return; - const missing = ensureBinary(); - if (missing) { - onUpdate({ error: missing }); - // onReady is only the FAST path (a successful build restarts us at - // once). The slow retry must stay armed regardless: a failed build - // never calls back, and a binary can also arrive from outside — scp'd - // in on a machine whose toolchain can't build (AB's broken CLT). - // Waiting on onReady alone deadlocked exactly that way in the Timers - // module: "building…" forever, with a working binary on disk. + const tool = captureTool(moduleDir); + if (tool.missing) { + // No binary for this platform: say so and look again later — a module + // update remounts us anyway, this is only belt and braces. + onUpdate({ error: tool.missing }); scheduleRestart(BAD_CONFIG_RETRY_MS); return; } @@ -175,7 +122,7 @@ function createSplListener(opts) { let stderrTail = ''; let buf = ''; - child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + child = spawn(tool.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); child.stderr.setEncoding('utf8'); child.stderr.on('data', (text) => { @@ -258,4 +205,4 @@ function createSplListener(opts) { }; } -module.exports = { createSplListener, ensureCaptureBinary }; +module.exports = { createSplListener, captureTool };