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