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))"]) + } }