Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0929af46ca | ||
|
|
c11389473f |
@@ -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