From 718e936d275845b9f90753e73d77e0d393f925ca Mon Sep 17 00:00:00 2001 From: Mostafa Abouzeid Date: Tue, 23 Jun 2026 14:22:10 +0300 Subject: [PATCH] Fix Bluetooth mic recording: AVAudioEngine self-kill on HFP switch Recording from a Bluetooth headset mic captured nothing and stopped about a second in. Opening the BT mic switches the headset to the HFP profile, which changes its *output* device format (e.g. 44.1kHz A2DP -> 16kHz). AVAudioEngine halts itself on that configuration change (and posts AVAudioEngineConfigurationChange), which AudioRecorder never handled, so capture died before producing audio. Replace the AVAudioEngine-based recorder with a CoreAudio input AudioQueue. A pure input queue has no output node tied to the playback device, so the HFP profile switch cannot interrupt it, and it targets the device directly by UID. (AVCaptureSession is not an option here: macOS does not expose Bluetooth headset mics as AVCaptureDevices.) Writes the same 16kHz mono 16-bit WAV whisper.cpp expects and preserves the AudioRecorder public API (prewarm/teardown/reload/startRecording/ stopRecording/preferredDeviceID), so AppDelegate is unchanged. --- Sources/OpenWisprLib/AudioRecorder.swift | 242 ++++++++++++++--------- 1 file changed, 147 insertions(+), 95 deletions(-) diff --git a/Sources/OpenWisprLib/AudioRecorder.swift b/Sources/OpenWisprLib/AudioRecorder.swift index bff4ea2..b668a07 100644 --- a/Sources/OpenWisprLib/AudioRecorder.swift +++ b/Sources/OpenWisprLib/AudioRecorder.swift @@ -1,138 +1,190 @@ -import AVFoundation +import AudioToolbox import CoreAudio import Foundation -class AudioRecorder { - private var audioEngine: AVAudioEngine? - private var isRecording = false - private var currentOutputURL: URL? +/// Microphone capture using a CoreAudio **input AudioQueue** writing straight to +/// a 16 kHz mono 16-bit WAV (the format whisper.cpp expects). +/// +/// Why not AVAudioEngine: AVAudioEngine couples input and output. When recording +/// opens a Bluetooth headset's mic, the headset switches to HFP, which changes +/// its *output* device format (44.1 kHz A2DP → 16 kHz). AVAudioEngine halts on +/// that configuration change and posts AVAudioEngineConfigurationChange — so with +/// Bluetooth the engine died ~1s in and captured nothing. +/// +/// Why not AVCaptureSession: AVFoundation does not expose Bluetooth headset mics +/// as AVCaptureDevices on macOS, so it cannot record from them at all. +/// +/// An input AudioQueue is a pure capture path: it has no output node tied to the +/// playback device, so the HFP profile switch can't interrupt it, and it targets +/// the device directly by CoreAudio UID, so it works with Bluetooth mics. +final class AudioRecorder { var preferredDeviceID: AudioDeviceID? - func prewarm() { - guard audioEngine == nil else { return } - - let engine = AVAudioEngine() - - if let deviceID = preferredDeviceID, - deviceID != AudioDeviceManager.getDefaultInputDeviceID() { - setInputDevice(deviceID, on: engine) - } - - _ = engine.inputNode - engine.prepare() - audioEngine = engine - } + private var queue: AudioQueueRef? + private var audioFile: AudioFileID? + private var currentOutputURL: URL? + private var packetIndex: Int64 = 0 + private var isRecording = false - /// Stop and release the engine. Call before changing input device or on shutdown. + /// Serializes start/stop against the queue's input callback thread. + private let lock = NSLock() + + private static let bufferCount = 3 + /// 16-bit mono @ 16 kHz, ~0.1s per buffer. + private static let bufferByteSize: UInt32 = 16000 / 10 * 2 + + private var format: AudioStreamBasicDescription = { + var f = AudioStreamBasicDescription() + f.mSampleRate = 16000 + f.mFormatID = kAudioFormatLinearPCM + f.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked + f.mFramesPerPacket = 1 + f.mChannelsPerFrame = 1 + f.mBitsPerChannel = 16 + f.mBytesPerFrame = 2 + f.mBytesPerPacket = 2 + return f + }() + + /// AudioQueue starts in tens of ms and prewarming a Bluetooth device would + /// hold it in HFP while idle, so there is intentionally nothing to prewarm. + func prewarm() {} + + /// Stop and release any active capture. Safe to call when idle. func teardown() { - if isRecording { - audioEngine?.inputNode.removeTap(onBus: 0) - isRecording = false - currentOutputURL = nil - } - audioEngine?.stop() - audioEngine = nil + _ = stopRecording() } - /// Re-prewarm with the current preferredDeviceID. Use after a config change. + /// Nothing is held between recordings, so a config change just means the next + /// recording resolves the (possibly reassigned) device fresh. func reload() { teardown() - prewarm() } func startRecording(to outputURL: URL) throws { + lock.lock() + defer { lock.unlock() } guard !isRecording else { return } - if audioEngine == nil { - prewarm() + // 1. Create the destination WAV in the exact format whisper.cpp wants. + var fileFormat = format + var fileID: AudioFileID? + let createStatus = AudioFileCreateWithURL( + outputURL as CFURL, kAudioFileWAVEType, &fileFormat, .eraseFile, &fileID) + guard createStatus == noErr, let file = fileID else { + throw recorderError(createStatus, "Could not create audio file") } - guard let engine = audioEngine else { - throw NSError( - domain: "OpenWispr.AudioRecorder", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Audio engine is not available"] - ) + // 2. Create the input queue. + var newQueue: AudioQueueRef? + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + let queueStatus = AudioQueueNewInput( + &format, AudioRecorder.inputCallback, selfPtr, nil, nil, 0, &newQueue) + guard queueStatus == noErr, let q = newQueue else { + AudioFileClose(file) + throw recorderError(queueStatus, "Could not create input queue") } - try engine.start() - - let inputFmt = engine.inputNode.outputFormat(forBus: 0) - - let recordingFormat = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16000, - channels: 1, - interleaved: false - )! - - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVSampleRateKey: 16000, - AVNumberOfChannelsKey: 1, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - ] - - let file = try AVAudioFile(forWriting: outputURL, settings: settings) - let converter = AVAudioConverter(from: inputFmt, to: recordingFormat) - - engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFmt) { buffer, _ in - guard let converter = converter else { return } - - let convertedBuffer = AVAudioPCMBuffer( - pcmFormat: recordingFormat, - frameCapacity: AVAudioFrameCount( - Double(buffer.frameLength) * 16000.0 / inputFmt.sampleRate - ) - )! - - var error: NSError? - converter.convert(to: convertedBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return buffer + // 3. Pin the configured device by its stable UID so the queue records from + // the user's chosen mic rather than following the system default input. + if let id = preferredDeviceID, let uid = AudioDeviceManager.getDeviceUID(deviceID: id) { + var cfUID = uid as CFString + let setStatus = AudioQueueSetProperty( + q, kAudioQueueProperty_CurrentDevice, &cfUID, UInt32(MemoryLayout.size)) + if setStatus != noErr { + print("Warning: could not pin input device \(uid) on queue (status \(setStatus)); using default") } + } - if error == nil && convertedBuffer.frameLength > 0 { - try? file.write(from: convertedBuffer) + // 4. Allocate and enqueue capture buffers. + for _ in 0.. URL? { + lock.lock() + defer { lock.unlock() } guard isRecording else { return nil } isRecording = false + if let q = queue { + // Synchronous stop: blocks until the queue thread drains, so no input + // callback can run (or touch the file) after this returns. + AudioQueueStop(q, true) + AudioQueueDispose(q, true) + queue = nil + } + if let file = audioFile { + AudioFileClose(file) + audioFile = nil + } + let url = currentOutputURL currentOutputURL = nil + return url + } - audioEngine?.inputNode.removeTap(onBus: 0) - audioEngine?.stop() + // MARK: - Capture callback - return url + private static let inputCallback: AudioQueueInputCallback = { + userData, queue, buffer, _, numPackets, packetDescs in + guard let userData = userData else { return } + let recorder = Unmanaged.fromOpaque(userData).takeUnretainedValue() + recorder.handle(queue: queue, buffer: buffer, numPackets: numPackets, packetDescs: packetDescs) } - private func setInputDevice(_ deviceID: AudioDeviceID, on engine: AVAudioEngine) { - guard let audioUnit = engine.inputNode.audioUnit else { - print("Warning: could not access audio unit to set input device") - return + private func handle( + queue: AudioQueueRef, + buffer: AudioQueueBufferRef, + numPackets: UInt32, + packetDescs: UnsafePointer? + ) { + // Runs on the queue's internal thread. After AudioQueueStop(_, true) returns + // no further callbacks fire, so `audioFile` is only mutated when stopped. + if numPackets > 0, let file = audioFile { + var packets = numPackets + let status = AudioFileWritePackets( + file, false, buffer.pointee.mAudioDataByteSize, packetDescs, + packetIndex, &packets, buffer.pointee.mAudioData) + if status == noErr { + packetIndex += Int64(packets) + } } - - var devID = deviceID - let status = AudioUnitSetProperty( - audioUnit, - kAudioOutputUnitProperty_CurrentDevice, - kAudioUnitScope_Global, - 0, - &devID, - UInt32(MemoryLayout.size) - ) - if status != noErr { - print("Warning: failed to set audio input device (status: \(status))") + if isRecording { + AudioQueueEnqueueBuffer(queue, buffer, 0, nil) } } + + private func recorderError(_ status: OSStatus, _ message: String) -> NSError { + NSError( + domain: "OpenWispr.AudioRecorder", code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "\(message) (status \(status))"]) + } }