Timers 1.7.0: the LTC capture tool ships prebuilt, for Apple silicon and Intel
The tool used to be compiled by the macOS app build, or by the module itself on the booth with swiftc — and a catalog install got source only, which the unbundled 1.10.0 shell made every fresh install. Under the module guide's rule it now ships built: bin/ltc-capture-macos-arm64 and bin/ltc-capture-macos-x86_64, signed with the Developer ID (hardened runtime, audio-input entitlement), from native/ltc-capture.swift via native/build.sh. Nothing is built or looked for on the machine any more: captureTool() picks the binary for this platform, and where there is none — Windows, until a Windows implementation exists — timecode reports itself off in the tile and the health line while the rest of the module runs. The runtime build, its "building…" states and the device picker's build kick are gone with it. The old top-level binary is no longer gitignored (it is no longer written). 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
2817a444c3
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/zsh
|
||||
# Build this module's native helper for every macOS architecture it ships —
|
||||
# ../bin/ltc-capture-macos-arm64 and ../bin/ltc-capture-macos-x86_64 —
|
||||
# from ltc-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 timecode off.
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
NAME="ltc-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>
|
||||
@@ -0,0 +1,207 @@
|
||||
// ltc-capture — minimal CoreAudio input capture for the Timers module's
|
||||
// LTC listener.
|
||||
//
|
||||
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||
// samples from an input device to a pipe. Production machines rarely have
|
||||
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||
// overkill — this is the whole job, natively. 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:
|
||||
//
|
||||
// modules/propresenter-timers/native/build.sh (both architectures, ~10 s)
|
||||
//
|
||||
// ltc-capture --list
|
||||
// every input-capable device: name, input channels, sample rate
|
||||
// ltc-capture --device "Dante Via 16 Channel"
|
||||
// streams the device's inputs as interleaved s16le to stdout, at the
|
||||
// device's own sample rate and channel count (both printed to stderr
|
||||
// at start). The name matches exactly first, then as a substring.
|
||||
//
|
||||
// --device matches case-insensitively on a substring of the device name or
|
||||
// UID; ambiguity is an error listing the candidates. Capture uses an
|
||||
// AVAudioEngine input tap pinned to the chosen device, converting Float32
|
||||
// to clamped Int16 little-endian. stdout is samples only; every message
|
||||
// goes to stderr.
|
||||
//
|
||||
// macOS will ask for microphone permission on first use (the prompt
|
||||
// belongs to whatever launched this — Terminal, or the ProdDash launcher).
|
||||
// Over SSH there is no prompt to answer: grant it once at the console, or
|
||||
// approve the terminal app under System Settings → Privacy → Microphone.
|
||||
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
func fail(_ msg: String) -> Never {
|
||||
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
func note(_ msg: String) {
|
||||
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
}
|
||||
|
||||
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||
|
||||
struct InputDevice {
|
||||
let id: AudioDeviceID
|
||||
let name: String
|
||||
let uid: String
|
||||
let channels: Int
|
||||
let sampleRate: Double
|
||||
}
|
||||
|
||||
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var ref: Unmanaged<CFString>? = nil
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||
let err = withUnsafeMutablePointer(to: &ref) {
|
||||
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||
}
|
||||
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||
return value as String
|
||||
}
|
||||
|
||||
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var rate: Float64 = 0
|
||||
var size = UInt32(MemoryLayout<Float64>.size)
|
||||
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||
return rate
|
||||
}
|
||||
|
||||
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||
let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { listPtr.deallocate() }
|
||||
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
}
|
||||
|
||||
func listInputDevices() -> [InputDevice] {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr
|
||||
else { return [] }
|
||||
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr
|
||||
else { return [] }
|
||||
|
||||
var out: [InputDevice] = []
|
||||
for id in ids {
|
||||
let channels = inputChannelCount(id)
|
||||
if channels == 0 { continue }
|
||||
out.append(InputDevice(id: id,
|
||||
name: stringProperty(id, kAudioObjectPropertyName),
|
||||
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||
channels: channels,
|
||||
sampleRate: sampleRateProperty(id)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ── args ───────────────────────────────────────────────────────────── */
|
||||
|
||||
var wantList = false
|
||||
var wantDevice: String? = nil
|
||||
var argIdx = 1
|
||||
let argv = CommandLine.arguments
|
||||
while argIdx < argv.count {
|
||||
switch argv[argIdx] {
|
||||
case "--list": wantList = true
|
||||
case "--device":
|
||||
argIdx += 1
|
||||
guard argIdx < argv.count else { fail("--device needs a name (see --list)") }
|
||||
wantDevice = argv[argIdx]
|
||||
default: fail("unknown flag \(argv[argIdx]) — use --list or --device <name>")
|
||||
}
|
||||
argIdx += 1
|
||||
}
|
||||
|
||||
let devices = listInputDevices()
|
||||
|
||||
if wantList || wantDevice == nil {
|
||||
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||
note("input devices:")
|
||||
for d in devices {
|
||||
note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!,
|
||||
d.channels, d.sampleRate, d.uid))
|
||||
}
|
||||
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||
exit(wantList ? 0 : 2)
|
||||
}
|
||||
|
||||
let query = wantDevice!.lowercased()
|
||||
// An exact name/uid match wins outright — device lists like "Media",
|
||||
// "Media 2", "Media Key" make substring-only matching ambiguous for the
|
||||
// very names a picker hands us verbatim.
|
||||
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||
let matches = exact.count == 1 ? exact
|
||||
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||
guard matches.count == 1 else {
|
||||
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||
}
|
||||
let device = matches[0]
|
||||
|
||||
/* ── capture: pin the engine's input to the device, tap, s16le out ──── */
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||
var deviceID = device.id
|
||||
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||
kAudioUnitScope_Global, 0, &deviceID,
|
||||
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||
|
||||
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||
let channels = Int(format.channelCount)
|
||||
let rate = Int(format.sampleRate)
|
||||
guard channels > 0, rate > 0 else {
|
||||
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||
+ "(System Settings → Privacy & Security → Microphone)")
|
||||
}
|
||||
note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout")
|
||||
note("reader flags: --sr \(rate) --channels \(channels) --ch <the LTC channel, 1-based>")
|
||||
|
||||
let stdoutHandle = FileHandle.standardOutput
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||
var bytes = Data(count: frames * channels * 2)
|
||||
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||
let out = raw.bindMemory(to: Int16.self)
|
||||
for f in 0..<frames {
|
||||
for c in 0..<channels {
|
||||
let v = floats[c][f]
|
||||
let clamped = max(-1.0, min(1.0, v))
|
||||
out[f * channels + c] = Int16(clamped * 32767.0) // arm64 is little-endian
|
||||
}
|
||||
}
|
||||
}
|
||||
do { try stdoutHandle.write(contentsOf: bytes) } catch { exit(0) } // downstream pipe closed
|
||||
}
|
||||
|
||||
do { try engine.start() } catch {
|
||||
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||
+ "approve microphone access for the launching app and retry")
|
||||
}
|
||||
|
||||
signal(SIGINT) { _ in exit(0) }
|
||||
signal(SIGTERM) { _ in exit(0) }
|
||||
RunLoop.main.run()
|
||||
Reference in New Issue
Block a user