diff --git a/tools/ltc-capture.swift b/tools/ltc-capture.swift new file mode 100644 index 0000000..c82c676 --- /dev/null +++ b/tools/ltc-capture.swift @@ -0,0 +1,199 @@ +// ltc-capture — minimal CoreAudio input capture for the LTC reader. +// +// ProdDash's LTC reader (tools/ltc-reader.js) eats raw PCM on stdin and is +// normally fed by ffmpeg or sox — but production machines rarely have +// either, and shipping a third-party binary just to move samples from an +// input device to a pipe is overkill. This is the whole job, natively: +// +// swiftc -O -o tools/ltc-capture tools/ltc-capture.swift (once, ~5 s) +// +// tools/ltc-capture --list +// every input-capable device: name, input channels, sample rate +// tools/ltc-capture --device "Dante Via 16" \ +// | node tools/ltc-reader.js --sr 48000 --channels 16 --ch 5 ... +// 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 — pass them to the reader as --sr / --channels). +// +// --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? = nil + var size = UInt32(MemoryLayout?>.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.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.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.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 ") + } + 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 ") } + exit(wantList ? 0 : 2) +} + +let query = wantDevice!.lowercased() +let matches = 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.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 ") + +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..