import AVFoundation import CoreAudio // InputDeviceCapture — captures a single hardware INPUT device (mic / line-in / aux) on macOS and // emits 20 ms (960 samples/channel @ 48 kHz, interleaved int16) frames for the aux outgoing stream. // // The input-device analogue of ScreenAudioCapture (which captures system audio via // ScreenCaptureKit). The core already owns ONE capture device (the mic) and can't open a second // arbitrary input, so for the aux stream the client captures the chosen device here and feeds PCM // into the core via `vc_stream_feed_pcm` — the same external-feed pipeline screen audio uses. // // Device selection: an AVAudioEngine's input node wraps an AUHAL audio unit; setting // kAudioOutputUnitProperty_CurrentDevice on it pins capture to a specific Core Audio device. We // pass the device's *UID* (stable across reboots/replugs, unlike AudioDeviceID) and resolve it at // start. The tap delivers Float32; AVAudioConverter resamples/quantises to 48 kHz int16. final class InputDeviceCapture { /// Receives a full 20 ms frame: (interleaved int16 PCM, samplesPerChannel = 960, channels). typealias PcmHandler = (UnsafePointer, Int, UInt32) -> Void enum CaptureError: Error { case deviceNotFound, engineStartFailed(OSStatus) } private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz private let deviceUID: String? // nil = system default input device private let onPcm: PcmHandler private let engine = AVAudioEngine() private var converter: AVAudioConverter? private var outFormat: AVAudioFormat? private var channels = 1 /// Interleaved int16 carry-over between tap callbacks (the tap buffer doesn't align to 20 ms), /// drained in whole `frameSamplesPerChannel * channels` chunks. Only touched on the tap queue. private var pending: [Int16] = [] init(deviceUID: String?, onPcm: @escaping PcmHandler) { self.deviceUID = deviceUID self.onPcm = onPcm } /// Begin capture. Throws if the device can't be resolved or the engine fails to start. func start() throws { let input = engine.inputNode // Pin the engine's AUHAL to the chosen device (skip for default — the engine already uses // the system default input). Must happen before reading inputFormat, which changes with // the selected device. if let deviceUID, let devId = Self.deviceID(forUID: deviceUID) { var dev = devId let status = AudioUnitSetProperty(input.audioUnit!, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &dev, UInt32(MemoryLayout.size)) if status != noErr { throw CaptureError.engineStartFailed(status) } } else if deviceUID != nil { throw CaptureError.deviceNotFound } let inFormat = input.inputFormat(forBus: 0) channels = max(1, min(2, Int(inFormat.channelCount))) guard let out = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000, channels: AVAudioChannelCount(channels), interleaved: true) else { throw CaptureError.deviceNotFound } outFormat = out converter = AVAudioConverter(from: inFormat, to: out) input.installTap(onBus: 0, bufferSize: 960, format: inFormat) { [weak self] buf, _ in self?.process(buf) } engine.prepare() do { try engine.start() } catch { throw CaptureError.engineStartFailed(-1) } } /// Stop capture and tear down the engine. Safe to call multiple times. func stop() { engine.inputNode.removeTap(onBus: 0) if engine.isRunning { engine.stop() } converter = nil pending.removeAll(keepingCapacity: false) } // MARK: - Conversion (called on the tap's realtime thread) private func process(_ inBuf: AVAudioPCMBuffer) { guard let converter, let outFormat else { return } // Output capacity must cover up-sampling (e.g. 44.1 → 48 kHz) plus slack. let ratio = outFormat.sampleRate / inBuf.format.sampleRate let cap = AVAudioFrameCount(Double(inBuf.frameLength) * ratio + 32) guard cap > 0, let outBuf = AVAudioPCMBuffer(pcmFormat: outFormat, frameCapacity: cap) else { return } var fed = false var err: NSError? let status = converter.convert(to: outBuf, error: &err) { _, outStatus in if fed { outStatus.pointee = .noDataNow; return nil } fed = true outStatus.pointee = .haveData return inBuf } guard status != .error, outBuf.frameLength > 0, let ch = outBuf.int16ChannelData else { return } // Interleaved int16: all channels live in the first buffer (ch[0]). let n = Int(outBuf.frameLength) * channels pending.append(contentsOf: UnsafeBufferPointer(start: ch[0], count: n)) emit() } /// Fire `onPcm` for every whole 20 ms frame accumulated. private func emit() { let full = Self.frameSamplesPerChannel * channels while pending.count >= full { pending.withUnsafeBufferPointer { buf in onPcm(buf.baseAddress!, Self.frameSamplesPerChannel, UInt32(channels)) } pending.removeFirst(full) } } // MARK: - UID → AudioDeviceID resolution private static func deviceID(forUID uid: String) -> AudioDeviceID? { var addr = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyTranslateUIDToDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var deviceID = AudioDeviceID(0) var cfUID = uid as CFString var size = UInt32(MemoryLayout.size) let status = withUnsafeMutablePointer(to: &cfUID) { uidPtr -> OSStatus in AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, UInt32(MemoryLayout.size), uidPtr, &size, &deviceID) } return (status == noErr && deviceID != 0) ? deviceID : nil } } // InputDeviceInfo / InputDeviceEnumerator — Core Audio input-device enumeration for the aux-stream // picker. Separate from the core's vc_list_devices (whose ids are miniaudio-opaque and can't be // passed to Core Audio); the aux device is client-captured, so the picker uses device UIDs. struct InputDeviceInfo: Equatable { let uid: String // stable across reboots/replugs — what we persist let name: String let isDefault: Bool } enum InputDeviceEnumerator { /// All Core Audio devices that expose at least one input channel. static func list() -> [InputDeviceInfo] { let defaultUID = defaultInputUID() var result: [InputDeviceInfo] = [] for devID in allDeviceIDs() { guard inputChannelCount(devID) > 0 else { continue } guard let uid = stringProperty(devID, kAudioDevicePropertyDeviceUID) else { continue } let name = stringProperty(devID, kAudioObjectPropertyName) ?? stringProperty(devID, kAudioDevicePropertyDeviceNameCFString) ?? "Unknown input device" result.append(InputDeviceInfo(uid: uid, name: name, isDefault: uid == defaultUID)) } return result.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } // MARK: - Core Audio helpers private static func allDeviceIDs() -> [AudioDeviceID] { var addr = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var size = UInt32(0) guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr, size > 0 else { return [] } let count = Int(size) / MemoryLayout.size var ids = [AudioDeviceID](repeating: 0, count: count) let status = AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) return status == noErr ? ids : [] } private static func inputChannelCount(_ devID: AudioDeviceID) -> Int { var addr = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyStreamConfiguration, mScope: kAudioObjectPropertyScopeInput, mElement: kAudioObjectPropertyElementMain) var size = UInt32(0) guard AudioObjectGetPropertyDataSize(devID, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 } let bufList = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout.alignment) defer { bufList.deallocate() } guard AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, bufList) == noErr else { return 0 } let abl = UnsafeMutableAudioBufferListPointer( bufList.assumingMemoryBound(to: AudioBufferList.self)) return abl.reduce(0) { $0 + Int($1.mNumberChannels) } } private static func defaultInputUID() -> String? { var addr = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var devID = AudioDeviceID(0) var size = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &devID) == noErr, devID != 0 else { return nil } return stringProperty(devID, kAudioDevicePropertyDeviceUID) } private static func stringProperty(_ devID: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> String? { var addr = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var cf: CFString? = nil var size = UInt32(MemoryLayout.size) let status = withUnsafeMutablePointer(to: &cf) { ptr in AudioObjectGetPropertyData(devID, &addr, 0, nil, &size, ptr) } guard status == noErr else { return nil } return cf as String? } }