SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS app build had compiled it, with the module trying swiftc on the booth as a fallback — NA has been logging "spawn spl-capture ENOENT" since its catalog install. Under the module guide's rule it now ships built: bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with the Developer ID (hardened runtime, audio-input entitlement), from native/spl-capture.swift via native/build.sh. captureTool() picks the binary for this platform; where there is none — Windows, until a Windows implementation exists — measurement reports itself off in the health line and the device picker while the module stays mounted. The runtime build and its "building…" state are gone, and the old top-level binary is no longer gitignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c11389473f
commit
0929af46ca
@@ -16,6 +16,3 @@ launcher/macos/build/
|
|||||||
launcher/macos/.cache/
|
launcher/macos/.cache/
|
||||||
launcher/windows/build/
|
launcher/windows/build/
|
||||||
launcher/windows/.cache/
|
launcher/windows/.cache/
|
||||||
|
|
||||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
|
||||||
modules/spl-meter/spl-capture
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.4.3",
|
"version": "0.5.0",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
Executable
+48
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- The helper reads an audio input; under the hardened runtime that
|
||||||
|
needs saying. The permission itself comes from the launcher. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
// measurement is done here and only the answers cross the pipe.
|
// measurement is done here and only the answers cross the pipe.
|
||||||
//
|
//
|
||||||
// Everything below uses Accelerate, which ships with macOS. No third-party
|
// 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/native/build.sh (both architectures)
|
||||||
// modules/spl-meter/spl-capture.swift
|
|
||||||
//
|
//
|
||||||
// spl-capture --list
|
// spl-capture --list
|
||||||
// every input-capable device: name, input channels, sample rate
|
// every input-capable device: name, input channels, sample rate
|
||||||
+10
-20
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
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. */
|
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||||
const CALIBRATOR_DB = 94;
|
const CALIBRATOR_DB = 94;
|
||||||
@@ -120,12 +120,9 @@ module.exports = {
|
|||||||
pinger: null,
|
pinger: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Before anything else: the device picker shells out to this tool, so a
|
// The capture tool ships prebuilt in the module (bin/); with none for
|
||||||
// machine that installed the module from the catalog (source only — the
|
// this platform every reading is off and the health line says so.
|
||||||
// binary is gitignored) needs the build kicked off here. Waiting for a
|
state.toolMissing = captureTool(moduleDir).missing;
|
||||||
// listener would never happen: no tool means no device list, and no
|
|
||||||
// device means no listener.
|
|
||||||
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
|
|
||||||
|
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||||
@@ -185,9 +182,7 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
stop() { teardown(); },
|
stop() { teardown(); },
|
||||||
health() {
|
health() {
|
||||||
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
|
if (state.toolMissing) return { status: 'error', message: state.toolMissing };
|
||||||
return { status: 'error', message: state.toolMissing };
|
|
||||||
}
|
|
||||||
if (state.error) return { status: 'error', message: state.error };
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
const quiet = silentMs();
|
const quiet = silentMs();
|
||||||
@@ -311,11 +306,10 @@ module.exports = {
|
|||||||
/** Device list for the admin page. Runs the capture tool's --list,
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
which is the only thing that knows what CoreAudio can see. */
|
which is the only thing that knows what CoreAudio can see. */
|
||||||
'GET /devices': async (req, res) => {
|
'GET /devices': async (req, res) => {
|
||||||
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
|
const tool = captureTool(__dirname);
|
||||||
if (missing) {
|
if (tool.missing) {
|
||||||
if (state) state.toolMissing = missing;
|
log(`device list unavailable — ${tool.missing}`);
|
||||||
log(`device list unavailable — ${missing}`);
|
json(res, 200, { options: [], error: tool.missing });
|
||||||
json(res, 200, { options: [] });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -447,7 +441,7 @@ function teardown() {
|
|||||||
function listDevices() {
|
function listDevices() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
|
execFile(captureTool(__dirname).bin, ['--list'], { timeout: 10000 },
|
||||||
(err, stdout) => {
|
(err, stdout) => {
|
||||||
if (err && !stdout) { reject(err); return; }
|
if (err && !stdout) { reject(err); return; }
|
||||||
try { resolve(JSON.parse(stdout).devices || []); }
|
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) {
|
function json(res, code, body) {
|
||||||
const text = JSON.stringify(body);
|
const text = JSON.stringify(body);
|
||||||
res.writeHead(code, {
|
res.writeHead(code, {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
/**
|
/**
|
||||||
* Sound level listener — owns the native capture child.
|
* 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-<arch>, shipped prebuilt;
|
||||||
|
* source in native/) for the
|
||||||
* admin-configured device and channels, reads its JSON lines, and hands 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
|
* latest frame upward. All measurement happens in the child; this file is
|
||||||
* plumbing and supervision, nothing more.
|
* plumbing and supervision, nothing more.
|
||||||
@@ -13,12 +14,11 @@
|
|||||||
* { frame: {...}, meta: {...} } measuring
|
* { frame: {...}, meta: {...} } measuring
|
||||||
* { 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 tool ships prebuilt inside the module, one binary per
|
||||||
* module's ltc-capture. It normally arrives already built — compiled beside
|
* platform under bin/ (module guide, "Native helpers"), exactly like the
|
||||||
* the app, or copied from any Mac of the same architecture. When it is
|
* Timers module's ltc-capture: nothing is ever built on the machine running
|
||||||
* missing this will build it, but in the background and never on the event
|
* ProdDash. captureTool() picks the binary for this platform; when there is
|
||||||
* loop, and from module scope so the device picker can trigger it too — see
|
* none, measurement reports itself off and the rest of the module carries on.
|
||||||
* ensureCaptureBinary for what both of those cost the first time round.
|
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -31,72 +31,31 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
/* ── the capture tool: prebuilt, one binary per platform ───────────────── */
|
||||||
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
|
||||||
+ 'and copy it next to spl-capture.swift';
|
/** What the module ships in bin/, by platform and architecture. */
|
||||||
const BUILDING = 'Building the capture tool — this takes a minute or two';
|
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
|
* The capture tool for this machine. It is never built here and never
|
||||||
* everything that needs it.
|
* 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
|
* @returns {{bin:string, missing:string}}
|
||||||
* 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;
|
function captureTool(moduleDir, platform = process.platform, arch = process.arch) {
|
||||||
let buildFailed = false;
|
const name = HELPERS[platform]?.[arch] || '';
|
||||||
|
const bin = name ? path.join(moduleDir, 'bin', name) : '';
|
||||||
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
if (bin && fs.existsSync(bin)) return { bin, missing: '' };
|
||||||
const bin = path.join(moduleDir, 'spl-capture');
|
const here = `${platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform} ${arch}`;
|
||||||
if (fs.existsSync(bin)) return '';
|
return {
|
||||||
const src = bin + '.swift';
|
bin: '',
|
||||||
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
missing: `The module ships no sound level capture tool for ${here}${name ? ` (bin/${name} is missing)` : ''} — measurement is off on this machine`,
|
||||||
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;
|
const RESTART_MS = 5000;
|
||||||
@@ -114,7 +73,6 @@ const STALL_MS = 3000;
|
|||||||
*/
|
*/
|
||||||
function createSplListener(opts) {
|
function createSplListener(opts) {
|
||||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||||
const bin = path.join(moduleDir, 'spl-capture');
|
|
||||||
|
|
||||||
let child = null;
|
let child = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -124,13 +82,6 @@ function createSplListener(opts) {
|
|||||||
let meta = null;
|
let meta = null;
|
||||||
let lastFrameWall = 0;
|
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) {
|
function scheduleRestart(ms) {
|
||||||
if (stopped || restartTimer) return;
|
if (stopped || restartTimer) return;
|
||||||
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||||
@@ -150,15 +101,11 @@ function createSplListener(opts) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
const missing = ensureBinary();
|
const tool = captureTool(moduleDir);
|
||||||
if (missing) {
|
if (tool.missing) {
|
||||||
onUpdate({ error: missing });
|
// No binary for this platform: say so and look again later — a module
|
||||||
// onReady is only the FAST path (a successful build restarts us at
|
// update remounts us anyway, this is only belt and braces.
|
||||||
// once). The slow retry must stay armed regardless: a failed build
|
onUpdate({ error: tool.missing });
|
||||||
// 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.
|
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,7 +122,7 @@ function createSplListener(opts) {
|
|||||||
let stderrTail = '';
|
let stderrTail = '';
|
||||||
let buf = '';
|
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.setEncoding('utf8');
|
||||||
child.stderr.on('data', (text) => {
|
child.stderr.on('data', (text) => {
|
||||||
@@ -258,4 +205,4 @@ function createSplListener(opts) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSplListener, ensureCaptureBinary };
|
module.exports = { createSplListener, captureTool };
|
||||||
|
|||||||
Reference in New Issue
Block a user