From 6001376a13195af0830df565e4c6558d6a1b4cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Thu, 30 Jul 2026 19:50:28 -0300 Subject: [PATCH 1/7] audio: rebuild the engine for each recording UNVERIFIED -- not reproduced locally, do not upstream as-is. A long-lived AVAudioEngine reports the input format it cached at construction, so after the default device changes installTap gets a format the bus no longer has and AVFAudio raises 'com.apple.coreaudio.avfaudio': "Failed to create tap due to format mismatch, ". That is an ObjC exception, so the daemon dies rather than throwing. Confirmed the format is stale (engine reported 48000 Hz while the device sat at 44100 Hz) but installTap tolerates a rate-only divergence, so the failing condition needs a device identity change to reproduce. --- Sources/parrot/Audio/AudioCapture.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/parrot/Audio/AudioCapture.swift b/Sources/parrot/Audio/AudioCapture.swift index 5ef1d55b..2b0ba865 100644 --- a/Sources/parrot/Audio/AudioCapture.swift +++ b/Sources/parrot/Audio/AudioCapture.swift @@ -12,7 +12,7 @@ final class AudioCapture { static let targetSampleRate: Double = 16_000 - private let engine = AVAudioEngine() + private var engine = AVAudioEngine() private var converter: AVAudioConverter? private var samples: [Float] = [] private var isRecording = false @@ -26,6 +26,11 @@ final class AudioCapture { func start() throws { guard !isRecording else { return } + // A reused engine keeps the input format it had at construction, so once + // the default device changes installTap throws a format mismatch and + // kills the process. A fresh engine always sees current hardware. + engine = AVAudioEngine() + let input = engine.inputNode let inputFormat = input.outputFormat(forBus: 0) From 96024aec17ce52d56cffc04d49ad1b27eb615fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Thu, 30 Jul 2026 20:14:49 -0300 Subject: [PATCH 2/7] menu bar: pick the input device Records from the chosen microphone without touching the system default input, so other apps are unaffected. The preference is stored as the device UID, not the AudioDeviceID: CoreAudio reassigns the numeric id across reboots and reconnects, so a stored id can silently point at a different microphone. It resolves to an id per recording, and an absent device falls back to the system default until it reappears. AudioCapture binds the node with auAudioUnit.setDeviceID(_:) before reading the input format, since the node reports the format of whichever device it is bound to. The submenu is rebuilt in menuWillOpen so a newly plugged mic shows up without watching CoreAudio for device changes. --- Sources/parrot/Audio/AudioCapture.swift | 14 ++- Sources/parrot/Audio/InputDeviceStore.swift | 105 ++++++++++++++++++++ Sources/parrot/Parrot.swift | 5 +- Sources/parrot/UI/MenuBarController.swift | 45 ++++++++- 4 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 Sources/parrot/Audio/InputDeviceStore.swift diff --git a/Sources/parrot/Audio/AudioCapture.swift b/Sources/parrot/Audio/AudioCapture.swift index 2b0ba865..3c5e99e3 100644 --- a/Sources/parrot/Audio/AudioCapture.swift +++ b/Sources/parrot/Audio/AudioCapture.swift @@ -23,7 +23,8 @@ final class AudioCapture { var onLevel: ((Float) -> Void)? /// Begin recording. Idempotent — calling while already recording is a no-op. - func start() throws { + /// `device` nil records from the system default input. + func start(device: AudioDeviceID? = nil) throws { guard !isRecording else { return } // A reused engine keeps the input format it had at construction, so once @@ -32,6 +33,17 @@ final class AudioCapture { engine = AVAudioEngine() let input = engine.inputNode + if let device { + // Must precede the format read: the node reports the format of + // whichever device it is bound to. + do { + try input.auAudioUnit.setDeviceID(device) + } catch { + FileHandle.standardError.write(Data( + "input device unavailable, using system default: \(error)\n".utf8 + )) + } + } let inputFormat = input.outputFormat(forBus: 0) let targetFormat = AVAudioFormat( diff --git a/Sources/parrot/Audio/InputDeviceStore.swift b/Sources/parrot/Audio/InputDeviceStore.swift new file mode 100644 index 00000000..aebc5b03 --- /dev/null +++ b/Sources/parrot/Audio/InputDeviceStore.swift @@ -0,0 +1,105 @@ +import CoreAudio +import Foundation + +/// Which microphone parrot records from: chosen in the menu bar, remembered +/// across restarts, resolved fresh for every recording. +/// +/// Devices are remembered by UID rather than AudioDeviceID, because the numeric +/// id is reassigned across reboots and reconnects — a stored id can silently +/// point at a different microphone. +final class InputDeviceStore { + struct Device { + let id: AudioDeviceID + let uid: String + let name: String + } + + private static let selectionKey = "inputDeviceUID" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// UID the user picked, or nil to follow the system default input. + var selectedUID: String? { + get { defaults.string(forKey: Self.selectionKey) } + set { + if let newValue { + defaults.set(newValue, forKey: Self.selectionKey) + } else { + defaults.removeObject(forKey: Self.selectionKey) + } + } + } + + /// Every device that can record, in the order CoreAudio reports them. + func available() -> [Device] { + deviceIDs().compactMap { id in + guard inputChannels(id) > 0, let uid = uid(of: id) else { return nil } + return Device(id: id, uid: uid, name: name(of: id) ?? uid) + } + } + + /// Device to record from now. nil means follow the system default, which is + /// also what happens when the remembered device is currently unplugged. + func resolved() -> Device? { + guard let selectedUID else { return nil } + return available().first { $0.uid == selectedUID } + } + + // MARK: - CoreAudio reads + + private func deviceIDs() -> [AudioDeviceID] { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + let system = AudioObjectID(kAudioObjectSystemObject) + guard AudioObjectGetPropertyDataSize(system, &addr, 0, nil, &size) == noErr else { return [] } + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData(system, &addr, 0, nil, &size, &ids) == noErr else { return [] } + return ids + } + + private func inputChannels(_ 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 raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: 16) + defer { raw.deallocate() } + guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, raw) == noErr else { return 0 } + let list = raw.assumingMemoryBound(to: AudioBufferList.self) + return UnsafeMutableAudioBufferListPointer(list).reduce(0) { $0 + Int($1.mNumberChannels) } + } + + private func uid(of id: AudioDeviceID) -> String? { + string(id, kAudioDevicePropertyDeviceUID) + } + + private func name(of id: AudioDeviceID) -> String? { + string(id, kAudioObjectPropertyName) + } + + private func string(_ id: AudioDeviceID, _ selector: AudioObjectPropertySelector) -> String? { + var addr = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var value: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = withUnsafeMutablePointer(to: &value) { + AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0) + } + guard status == noErr, let value else { return nil } + return value.takeRetainedValue() as String + } +} diff --git a/Sources/parrot/Parrot.swift b/Sources/parrot/Parrot.swift index 05a69ebe..e5a693e5 100644 --- a/Sources/parrot/Parrot.swift +++ b/Sources/parrot/Parrot.swift @@ -82,20 +82,21 @@ struct Run: ParsableCommand { app.setActivationPolicy(.accessory) let monitor = HotkeyMonitor(debug: debugHotkey) + let devices = InputDeviceStore() let capture = AudioCapture() let dumpWav = self.dumpWav let overlay: RecordingOverlay? = noOverlay ? nil : MainActor.assumeIsolated { RecordingOverlay() } if let overlay { capture.onLevel = { level in overlay.pushLevel(level) } } - let menuBar = MainActor.assumeIsolated { MenuBarController(modelID: chosenModel.id) } + let menuBar = MainActor.assumeIsolated { MenuBarController(modelID: chosenModel.id, devices: devices) } do { try monitor.start { event in switch event { case .pressed: do { - try capture.start() + try capture.start(device: devices.resolved()?.id) FileHandle.standardError.write(Data("● recording\n".utf8)) MainActor.assumeIsolated { overlay?.show(.recording) diff --git a/Sources/parrot/UI/MenuBarController.swift b/Sources/parrot/UI/MenuBarController.swift index 366ab060..fc70186f 100644 --- a/Sources/parrot/UI/MenuBarController.swift +++ b/Sources/parrot/UI/MenuBarController.swift @@ -4,14 +4,17 @@ import AppKit /// a glance and provides the only persistent control surface for the daemon /// (since we run as `.accessory` — no dock icon, no main window). @MainActor -final class MenuBarController { +final class MenuBarController: NSObject, NSMenuDelegate { private let statusItem: NSStatusItem private let modelLabel: NSMenuItem private let stateLabel: NSMenuItem + private let inputItem: NSMenuItem private let modelID: String + private let devices: InputDeviceStore - init(modelID: String) { + init(modelID: String, devices: InputDeviceStore) { self.modelID = modelID + self.devices = devices self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) let menu = NSMenu() @@ -25,6 +28,12 @@ final class MenuBarController { modelLabel.isEnabled = false menu.addItem(modelLabel) + inputItem = NSMenuItem(title: "Input", action: nil, keyEquivalent: "") + let inputMenu = NSMenu() + inputMenu.autoenablesItems = false + inputItem.submenu = inputMenu + menu.addItem(inputItem) + menu.addItem(.separator()) let quit = NSMenuItem( @@ -32,13 +41,45 @@ final class MenuBarController { action: #selector(quitClicked), keyEquivalent: "q" ) + super.init() + quit.target = self menu.addItem(quit) + // Rebuild the device list on open so plugging a mic in is reflected + // without watching CoreAudio for device changes. + menu.delegate = self + statusItem.menu = menu configureButton(recording: false) } + func menuWillOpen(_ menu: NSMenu) { + guard let submenu = inputItem.submenu else { return } + submenu.removeAllItems() + + let selected = devices.selectedUID + submenu.addItem(inputChoice(title: "Automatic (follow system)", uid: nil, checked: selected == nil)) + submenu.addItem(.separator()) + for device in devices.available() { + submenu.addItem(inputChoice(title: device.name, uid: device.uid, checked: device.uid == selected)) + } + } + + private func inputChoice(title: String, uid: String?, checked: Bool) -> NSMenuItem { + let item = NSMenuItem(title: title, action: #selector(inputSelected), keyEquivalent: "") + item.target = self + item.representedObject = uid + item.state = checked ? .on : .off + return item + } + + /// Only writes the preference — the next recording resolves it, so there is + /// nothing to notify. + @objc private func inputSelected(_ sender: NSMenuItem) { + devices.selectedUID = sender.representedObject as? String + } + func setRecording(_ recording: Bool) { stateLabel.title = recording ? "● recording" : "idle · hold fn to dictate" } From 3716445ced38994eb5afc5ca078e46a408d96a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Thu, 30 Jul 2026 20:46:48 -0300 Subject: [PATCH 3/7] menu bar: label the follow-system row 'Same as System' --- Sources/parrot/UI/MenuBarController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/parrot/UI/MenuBarController.swift b/Sources/parrot/UI/MenuBarController.swift index fc70186f..841b0399 100644 --- a/Sources/parrot/UI/MenuBarController.swift +++ b/Sources/parrot/UI/MenuBarController.swift @@ -59,7 +59,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { submenu.removeAllItems() let selected = devices.selectedUID - submenu.addItem(inputChoice(title: "Automatic (follow system)", uid: nil, checked: selected == nil)) + submenu.addItem(inputChoice(title: "Same as System", uid: nil, checked: selected == nil)) submenu.addItem(.separator()) for device in devices.available() { submenu.addItem(inputChoice(title: device.name, uid: device.uid, checked: device.uid == selected)) From e8f4529e18b09a84e33231d56071294d961f7da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Thu, 30 Jul 2026 21:19:27 -0300 Subject: [PATCH 4/7] menu bar: hide CoreAudio's private aggregate from the input list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreAudio creates an aggregate device per audio client the moment an input node is touched, named CADefaultDeviceAggregate--N. It is visible only to that client, reports input channels, and is not flagged hidden — so parrot was listing its own plumbing as a selectable microphone. The composition dictionary of those aggregates carries the private key, which is what separates them from an aggregate the user built in Audio MIDI Setup; the latter is not private and stays listed. --- Sources/parrot/Audio/InputDeviceStore.swift | 37 +++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/parrot/Audio/InputDeviceStore.swift b/Sources/parrot/Audio/InputDeviceStore.swift index aebc5b03..abf2f43a 100644 --- a/Sources/parrot/Audio/InputDeviceStore.swift +++ b/Sources/parrot/Audio/InputDeviceStore.swift @@ -34,10 +34,12 @@ final class InputDeviceStore { } } - /// Every device that can record, in the order CoreAudio reports them. + /// Every device a person could record from, in the order CoreAudio reports + /// them. Excludes the private aggregate CoreAudio creates per audio client — + /// our own plumbing, which is otherwise indistinguishable from a microphone. func available() -> [Device] { deviceIDs().compactMap { id in - guard inputChannels(id) > 0, let uid = uid(of: id) else { return nil } + guard inputChannels(id) > 0, !isPrivateAggregate(id), let uid = uid(of: id) else { return nil } return Device(id: id, uid: uid, name: name(of: id) ?? uid) } } @@ -80,6 +82,37 @@ final class InputDeviceStore { return UnsafeMutableAudioBufferListPointer(list).reduce(0) { $0 + Int($1.mNumberChannels) } } + /// Aggregate devices CoreAudio builds for its own clients are flagged private + /// in their composition. They report input channels and are not hidden, so + /// this is the only thing separating them from a real microphone. Aggregates + /// the user built in Audio MIDI Setup are not private and stay listed. + private func isPrivateAggregate(_ id: AudioDeviceID) -> Bool { + var transportAddr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyTransportType, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var transport: UInt32 = 0 + var transportSize = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(id, &transportAddr, 0, nil, &transportSize, &transport) == noErr, + transport == kAudioDeviceTransportTypeAggregate + else { return false } + + var addr = AudioObjectPropertyAddress( + mSelector: kAudioAggregateDevicePropertyComposition, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var value: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = withUnsafeMutablePointer(to: &value) { + AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0) + } + guard status == noErr, let value else { return false } + let composition = value.takeRetainedValue() as? [String: Any] + return composition?[kAudioAggregateDeviceIsPrivateKey] as? Int == 1 + } + private func uid(of id: AudioDeviceID) -> String? { string(id, kAudioDevicePropertyDeviceUID) } From 1cb939c27391ce5e3002482987bfaf9cd2a84527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Fri, 31 Jul 2026 14:49:25 -0300 Subject: [PATCH 5/7] audio: capture through AUHAL so the device choice is honoured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AVAudioEngine's input node binds to whatever input is current the moment the node is first touched, and nothing reconfigures it afterwards. Measured against a Bluetooth headset while the built-in mic stayed the system default: auAudioUnit.setDeviceID(_:) noErr, 0 frames in 3s AudioUnitSetProperty(CurrentDevice) noErr, 0 frames in 3s setDeviceID + engine.reset() noErr, 0 frames in 3s headset as system default, no binding 115200 frames, peak 0.0212 Every binding call reports success and then delivers silence, which is how the menu's device picker could look correct and record nothing. A standalone AUHAL unit honours kAudioOutputUnitProperty_CurrentDevice: same headset, same conditions, 48000 frames in 3s. Verified end to end afterwards — 5.06 s captured at rms 0.028 through the headset with the system default left on the built-in microphone. Two things leave the code with AVAudioEngine. AUHAL converts to 16 kHz mono itself, so AVAudioConverter is gone; and there is no installTap(onBus:format:), so the uncatchable AVFAudio format-mismatch exception that could terminate the daemon on a device change is no longer reachable. The unit is disposed on stop so a Bluetooth headset does not keep its microphone link open. --- Sources/parrot/Audio/AudioCapture.swift | 251 ++++++++++++++---------- 1 file changed, 142 insertions(+), 109 deletions(-) diff --git a/Sources/parrot/Audio/AudioCapture.swift b/Sources/parrot/Audio/AudioCapture.swift index 3c5e99e3..45c057ae 100644 --- a/Sources/parrot/Audio/AudioCapture.swift +++ b/Sources/parrot/Audio/AudioCapture.swift @@ -1,25 +1,44 @@ +import AudioToolbox import AVFoundation +import CoreAudio import Foundation /// Captures microphone audio while recording is active and returns a 16 kHz -/// mono Float32 buffer when stopped. Format-converts on the fly so callers -/// don't have to worry about the input device's native rate. +/// mono Float32 buffer when stopped. +/// +/// Built on a standalone AUHAL unit rather than `AVAudioEngine` because only +/// AUHAL honours a device choice: `AVAudioEngine`'s input node binds to whatever +/// input is current the moment the node is first touched, and setting the device +/// afterwards — via `auAudioUnit.setDeviceID`, via `AudioUnitSetProperty`, or +/// with an `engine.reset()` in between — returns `noErr` and then delivers +/// silence. Measured on a Bluetooth headset: 0 frames in 3 s for all three, +/// against 48 000 frames for the same device through AUHAL. +/// +/// AUHAL also converts to the target format itself, so there is no +/// `AVAudioConverter` in the path, and no `installTap(onBus:format:)` — which +/// removes the format-mismatch exception that could terminate the daemon when +/// the input device changed. final class AudioCapture { enum CaptureError: Error { - case engineStartFailed(Error) - case converterCreationFailed + case unavailable + case configurationFailed(OSStatus) + case startFailed(OSStatus) } static let targetSampleRate: Double = 16_000 - private var engine = AVAudioEngine() - private var converter: AVAudioConverter? + /// Matches the old tap size; also the render buffer we preallocate, so the + /// audio thread never allocates. + private static let framesPerSlice: UInt32 = 4096 + + private var unit: AudioUnit? + private var buffer: AVAudioPCMBuffer? private var samples: [Float] = [] private var isRecording = false private let lock = NSLock() /// Called for every audio buffer with the buffer's RMS level (0…~1). - /// Invoked on an arbitrary thread; hop to main if you touch UI. + /// Invoked on the audio thread; hop to main if you touch UI. var onLevel: ((Float) -> Void)? /// Begin recording. Idempotent — calling while already recording is a no-op. @@ -27,54 +46,71 @@ final class AudioCapture { func start(device: AudioDeviceID? = nil) throws { guard !isRecording else { return } - // A reused engine keeps the input format it had at construction, so once - // the default device changes installTap throws a format mismatch and - // kills the process. A fresh engine always sees current hardware. - engine = AVAudioEngine() - - let input = engine.inputNode - if let device { - // Must precede the format read: the node reports the format of - // whichever device it is bound to. - do { - try input.auAudioUnit.setDeviceID(device) - } catch { - FileHandle.standardError.write(Data( - "input device unavailable, using system default: \(error)\n".utf8 - )) - } + var description = AudioComponentDescription( + componentType: kAudioUnitType_Output, + componentSubType: kAudioUnitSubType_HALOutput, + componentManufacturer: kAudioUnitManufacturer_Apple, + componentFlags: 0, + componentFlagsMask: 0 + ) + guard let component = AudioComponentFindNext(nil, &description) else { + throw CaptureError.unavailable + } + var unit: AudioUnit? + try check(AudioComponentInstanceNew(component, &unit)) + guard let unit else { throw CaptureError.unavailable } + self.unit = unit + + var enable: UInt32 = 1 + try check(AudioUnitSetProperty(unit, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Input, 1, + &enable, UInt32(MemoryLayout.size))) + var disable: UInt32 = 0 + try check(AudioUnitSetProperty(unit, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Output, 0, + &disable, UInt32(MemoryLayout.size))) + + // Left unset, AUHAL follows the system default input. + if var device { + try check(AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, + &device, UInt32(MemoryLayout.size))) } - let inputFormat = input.outputFormat(forBus: 0) - - let targetFormat = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: AudioCapture.targetSampleRate, - channels: 1, - interleaved: false - )! - guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { - throw CaptureError.converterCreationFailed + guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, + sampleRate: Self.targetSampleRate, + channels: 1, interleaved: false) else { + throw CaptureError.unavailable } - self.converter = converter + var asbd = format.streamDescription.pointee + try check(AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, 1, + &asbd, UInt32(MemoryLayout.size))) + + var slice = Self.framesPerSlice + try check(AudioUnitSetProperty(unit, kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, 0, + &slice, UInt32(MemoryLayout.size))) + buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: Self.framesPerSlice) + + var callback = AURenderCallbackStruct( + inputProc: captureRenderCallback, + inputProcRefCon: Unmanaged.passUnretained(self).toOpaque() + ) + try check(AudioUnitSetProperty(unit, kAudioOutputUnitProperty_SetInputCallback, + kAudioUnitScope_Global, 0, + &callback, UInt32(MemoryLayout.size))) lock.lock() samples.removeAll(keepingCapacity: true) lock.unlock() - // Tap with input format; convert inside the callback. - input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in - self?.process(buffer: buffer, converter: converter, targetFormat: targetFormat) - } - - engine.prepare() - do { - try engine.start() - } catch { - input.removeTap(onBus: 0) - throw CaptureError.engineStartFailed(error) + try check(AudioUnitInitialize(unit)) + let status = AudioOutputUnitStart(unit) + guard status == noErr else { + dispose() + throw CaptureError.startFailed(status) } - isRecording = true } @@ -82,9 +118,14 @@ final class AudioCapture { @discardableResult func stop() -> [Float] { guard isRecording else { return [] } - engine.stop() - engine.inputNode.removeTap(onBus: 0) isRecording = false + if let unit { + AudioOutputUnitStop(unit) + AudioUnitUninitialize(unit) + } + // Dispose so the device is released; a Bluetooth headset keeps its + // microphone link open otherwise. + dispose() lock.lock() let captured = samples @@ -93,39 +134,18 @@ final class AudioCapture { return captured } - private func process( - buffer: AVAudioPCMBuffer, - converter: AVAudioConverter, - targetFormat: AVAudioFormat - ) { - // Output buffer capacity scales with sample-rate ratio. - let ratio = targetFormat.sampleRate / buffer.format.sampleRate - let outCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64 - - guard let outBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, - frameCapacity: outCapacity - ) else { return } - - var consumed = false - let inputBlock: AVAudioConverterInputBlock = { _, status in - if consumed { - status.pointee = .noDataNow - return nil - } - consumed = true - status.pointee = .haveData - return buffer - } - - var error: NSError? - let status = converter.convert(to: outBuffer, error: &error, withInputFrom: inputBlock) - guard status != .error, let channelData = outBuffer.floatChannelData else { return } - - let count = Int(outBuffer.frameLength) - let ptr = channelData[0] - let chunk = Array(UnsafeBufferPointer(start: ptr, count: count)) - + fileprivate func render( + flags: UnsafeMutablePointer, + timestamp: UnsafePointer, + bus: UInt32, + frames: UInt32 + ) -> OSStatus { + guard let unit, let buffer, frames <= buffer.frameCapacity else { return noErr } + buffer.frameLength = frames + let status = AudioUnitRender(unit, flags, timestamp, bus, frames, buffer.mutableAudioBufferList) + guard status == noErr, let channel = buffer.floatChannelData?[0] else { return status } + + let chunk = Array(UnsafeBufferPointer(start: channel, count: Int(frames))) lock.lock() samples.append(contentsOf: chunk) lock.unlock() @@ -133,54 +153,67 @@ final class AudioCapture { if let onLevel { onLevel(computeRMS(chunk)) } + return noErr + } + + private func dispose() { + if let unit { AudioComponentInstanceDispose(unit) } + unit = nil + buffer = nil + } + + private func check(_ status: OSStatus) throws { + guard status != noErr else { return } + dispose() + throw CaptureError.configurationFailed(status) } } +/// C callbacks carry no context, so the instance travels through +/// `inputProcRefCon`. Unretained: the unit never outlives its AudioCapture. +private let captureRenderCallback: AURenderCallback = { refCon, flags, timestamp, bus, frames, _ in + let capture = Unmanaged.fromOpaque(refCon).takeUnretainedValue() + return capture.render(flags: flags, timestamp: timestamp, bus: bus, frames: frames) +} + // MARK: - WAV writer (for debugging M3 captures) enum WAVWriter { /// Write Float32 mono samples as 16-bit PCM WAV to `path`. static func write(samples: [Float], sampleRate: Int, to path: String) throws { - let bytesPerSample = 2 - let dataSize = samples.count * bytesPerSample - var data = Data() - data.append(contentsOf: Array("RIFF".utf8)) - data.append(uint32LE(36 + UInt32(dataSize))) - data.append(contentsOf: Array("WAVE".utf8)) - data.append(contentsOf: Array("fmt ".utf8)) - data.append(uint32LE(16)) // fmt chunk size - data.append(uint16LE(1)) // PCM - data.append(uint16LE(1)) // mono + let byteCount = samples.count * 2 + data.append("RIFF".data(using: .ascii)!) + data.append(uint32LE(UInt32(36 + byteCount))) + data.append("WAVE".data(using: .ascii)!) + data.append("fmt ".data(using: .ascii)!) + data.append(uint32LE(16)) + data.append(uint16LE(1)) + data.append(uint16LE(1)) data.append(uint32LE(UInt32(sampleRate))) - data.append(uint32LE(UInt32(sampleRate * bytesPerSample))) - data.append(uint16LE(UInt16(bytesPerSample))) // block align - data.append(uint16LE(16)) // bits per sample - data.append(contentsOf: Array("data".utf8)) - data.append(uint32LE(UInt32(dataSize))) - - for s in samples { - let clamped = max(-1.0, min(1.0, s)) - let i = Int16(clamped * 32767.0) - data.append(uint16LE(UInt16(bitPattern: i))) + data.append(uint32LE(UInt32(sampleRate * 2))) + data.append(uint16LE(2)) + data.append(uint16LE(16)) + data.append("data".data(using: .ascii)!) + data.append(uint32LE(UInt32(byteCount))) + for sample in samples { + let clamped = max(-1, min(1, sample)) + data.append(uint16LE(UInt16(bitPattern: Int16(clamped * 32767)))) } - try data.write(to: URL(fileURLWithPath: path)) } private static func uint32LE(_ v: UInt32) -> Data { - var x = v.littleEndian - return Data(bytes: &x, count: 4) + Data([UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8((v >> 24) & 0xff)]) } + private static func uint16LE(_ v: UInt16) -> Data { - var x = v.littleEndian - return Data(bytes: &x, count: 2) + Data([UInt8(v & 0xff), UInt8((v >> 8) & 0xff)]) } } func computeRMS(_ samples: [Float]) -> Float { guard !samples.isEmpty else { return 0 } - var sum: Double = 0 - for s in samples { sum += Double(s * s) } - return Float((sum / Double(samples.count)).squareRoot()) + let sum = samples.reduce(Float(0)) { $0 + $1 * $1 } + return (sum / Float(samples.count)).squareRoot() } From f94c0fc73fbbf50b59f8e4961f036eb915a60950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Fri, 31 Jul 2026 15:00:51 -0300 Subject: [PATCH 6/7] audio: bind AUHAL at the device's own rate and resample here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUHAL input does not resample. Asking a 48 kHz device to deliver 16 kHz renders nothing at all: the built-in microphone captured 0.00 s while a 16 kHz Bluetooth headset — whose native rate already matched the request — captured fine. The first version of this only ever ran against the headset, so the conversion path was never exercised. Read the device's own format from the input scope, ask AUHAL for float at that same rate, and put AVAudioConverter back in charge of reaching 16 kHz mono. AUHAL then does only what it can do: pick the device and hand over float. `AudioUnitRender` failures are now logged. Swallowing that status is what made a failed render look exactly like a microphone that heard nothing. --- Sources/parrot/Audio/AudioCapture.swift | 65 ++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/Sources/parrot/Audio/AudioCapture.swift b/Sources/parrot/Audio/AudioCapture.swift index 45c057ae..174ed22d 100644 --- a/Sources/parrot/Audio/AudioCapture.swift +++ b/Sources/parrot/Audio/AudioCapture.swift @@ -33,6 +33,10 @@ final class AudioCapture { private var unit: AudioUnit? private var buffer: AVAudioPCMBuffer? + /// AUHAL input does not resample: asking a 48 kHz device for 16 kHz renders + /// nothing at all. It is bound at the device's own rate and converted here. + private var converter: AVAudioConverter? + private var targetFormat: AVAudioFormat? private var samples: [Float] = [] private var isRecording = false private let lock = NSLock() @@ -77,12 +81,31 @@ final class AudioCapture { &device, UInt32(MemoryLayout.size))) } - guard let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, - sampleRate: Self.targetSampleRate, - channels: 1, interleaved: false) else { + // Read what the device actually produces, then ask AUHAL only for a + // float layout at that same rate — the one conversion it will do. + var native = AudioStreamBasicDescription() + var nativeSize = UInt32(MemoryLayout.size) + try check(AudioUnitGetProperty(unit, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, 1, &native, &nativeSize)) + + guard let sourceFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: native.mSampleRate, + channels: max(1, AVAudioChannelCount(native.mChannelsPerFrame)), + interleaved: false + ), let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: Self.targetSampleRate, + channels: 1, + interleaved: false + ), let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else { + dispose() throw CaptureError.unavailable } - var asbd = format.streamDescription.pointee + self.targetFormat = targetFormat + self.converter = converter + + var asbd = sourceFormat.streamDescription.pointee try check(AudioUnitSetProperty(unit, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Output, 1, &asbd, UInt32(MemoryLayout.size))) @@ -91,7 +114,7 @@ final class AudioCapture { try check(AudioUnitSetProperty(unit, kAudioUnitProperty_MaximumFramesPerSlice, kAudioUnitScope_Global, 0, &slice, UInt32(MemoryLayout.size))) - buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: Self.framesPerSlice) + buffer = AVAudioPCMBuffer(pcmFormat: sourceFormat, frameCapacity: Self.framesPerSlice) var callback = AURenderCallbackStruct( inputProc: captureRenderCallback, @@ -140,12 +163,36 @@ final class AudioCapture { bus: UInt32, frames: UInt32 ) -> OSStatus { - guard let unit, let buffer, frames <= buffer.frameCapacity else { return noErr } + guard let unit, let buffer, let converter, let targetFormat, + frames <= buffer.frameCapacity else { return noErr } buffer.frameLength = frames let status = AudioUnitRender(unit, flags, timestamp, bus, frames, buffer.mutableAudioBufferList) - guard status == noErr, let channel = buffer.floatChannelData?[0] else { return status } + guard status == noErr else { + // Never fail silently: a render that returns nothing used to look + // exactly like a microphone that heard nothing. + FileHandle.standardError.write(Data("audio render failed: \(status)\n".utf8)) + return status + } + + let ratio = targetFormat.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount(Double(frames) * ratio) + 64 + guard let out = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { + return noErr + } + var consumed = false + var error: NSError? + converter.convert(to: out, error: &error) { _, outStatus in + if consumed { + outStatus.pointee = .noDataNow + return nil + } + consumed = true + outStatus.pointee = .haveData + return buffer + } + guard error == nil, let channel = out.floatChannelData?[0] else { return noErr } - let chunk = Array(UnsafeBufferPointer(start: channel, count: Int(frames))) + let chunk = Array(UnsafeBufferPointer(start: channel, count: Int(out.frameLength))) lock.lock() samples.append(contentsOf: chunk) lock.unlock() @@ -160,6 +207,8 @@ final class AudioCapture { if let unit { AudioComponentInstanceDispose(unit) } unit = nil buffer = nil + converter = nil + targetFormat = nil } private func check(_ status: OSStatus) throws { From b00b3fe7642144c07fd8ad0678eeeb1c414cbf5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20=22Dezzy=22=20Victor?= Date: Fri, 31 Jul 2026 16:22:45 -0300 Subject: [PATCH 7/7] audio: write the device explicitly so "Same as System" records Left unwritten, kAudioOutputUnitProperty_CurrentDevice still reports the right device and AudioOutputUnitStart returns noErr, but the unit renders zero frames. Measured: 0 frames in 2 s unbound against 30400 bound to the same device id. The system default input is now resolved and written like any other choice, so the default preference is no longer the broken one. --- Sources/parrot/Audio/AudioCapture.swift | 31 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/Sources/parrot/Audio/AudioCapture.swift b/Sources/parrot/Audio/AudioCapture.swift index 174ed22d..5aa2efe9 100644 --- a/Sources/parrot/Audio/AudioCapture.swift +++ b/Sources/parrot/Audio/AudioCapture.swift @@ -74,12 +74,18 @@ final class AudioCapture { kAudioUnitScope_Output, 0, &disable, UInt32(MemoryLayout.size))) - // Left unset, AUHAL follows the system default input. - if var device { - try check(AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, - kAudioUnitScope_Global, 0, - &device, UInt32(MemoryLayout.size))) + // Writing this property is what wires the unit to a device's input + // stream. Left unwritten the unit still reports the right device and + // starts without error, but renders zero frames, so the system default + // is resolved and written like any other choice. + var resolved = device ?? Self.systemDefaultInput() + guard resolved != AudioDeviceID(kAudioObjectUnknown) else { + dispose() + throw CaptureError.unavailable } + try check(AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, + &resolved, UInt32(MemoryLayout.size))) // Read what the device actually produces, then ask AUHAL only for a // float layout at that same rate — the one conversion it will do. @@ -203,6 +209,21 @@ final class AudioCapture { return noErr } + /// The device the system currently records from. AUHAL will not resolve + /// this on its own, so it is read explicitly whenever the user has not + /// pinned a device. + private static func systemDefaultInput() -> AudioDeviceID { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var device = AudioDeviceID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), + &address, 0, nil, &size, &device) + return device + } + private func dispose() { if let unit { AudioComponentInstanceDispose(unit) } unit = nil