|
import AVFoundation |
|
import Accelerate |
|
|
|
// MARK: - Delegate Protocol |
|
|
|
/// Receives raw audio buffer callbacks from the microphone. |
|
public protocol MicrophoneBufferManagerDelegate: AnyObject { |
|
/// Called on every audio tap with a fresh buffer of raw PCM samples. |
|
/// - Parameters: |
|
/// - manager: The `MicrophoneBufferManager` that produced the buffer. |
|
/// - buffer: Raw `AVAudioPCMBuffer` containing interleaved/deinterleaved Float32 samples. |
|
/// - time: The audio timestamp of the buffer's first sample. |
|
func microphoneBufferManager( |
|
_ manager: MicrophoneBufferManager, |
|
didReceiveBuffer buffer: AVAudioPCMBuffer, |
|
at time: AVAudioTime |
|
) |
|
|
|
/// Called when a permission or hardware error occurs. |
|
func microphoneBufferManager( |
|
_ manager: MicrophoneBufferManager, |
|
didEncounterError error: MicrophoneBufferManager.MicError |
|
) |
|
} |
|
|
|
// MARK: - MicrophoneBufferManager |
|
|
|
/// Captures raw PCM audio from the default system microphone on macOS. |
|
/// |
|
/// Usage: |
|
/// ```swift |
|
/// let mic = MicrophoneBufferManager(sampleRate: 16_000, bufferSize: 4096) |
|
/// mic.delegate = self |
|
/// mic.requestPermissionAndStart() |
|
/// ``` |
|
public final class MicrophoneBufferManager { |
|
|
|
// MARK: - Public types |
|
|
|
public enum MicError: Error, LocalizedError { |
|
case permissionDenied |
|
case noInputDeviceFound |
|
case engineSetupFailed(underlying: Error) |
|
case alreadyRunning |
|
case notRunning |
|
|
|
public var errorDescription: String? { |
|
switch self { |
|
case .permissionDenied: return "Microphone access was denied." |
|
case .noInputDeviceFound: return "No audio input device found." |
|
case .engineSetupFailed(let e): return "Engine setup failed: \(e.localizedDescription)" |
|
case .alreadyRunning: return "MicrophoneBufferManager is already running." |
|
case .notRunning: return "MicrophoneBufferManager is not running." |
|
} |
|
} |
|
} |
|
|
|
// MARK: - Public properties |
|
|
|
public weak var delegate: MicrophoneBufferManagerDelegate? |
|
|
|
/// The sample rate the engine is configured to capture at. |
|
public let sampleRate: Double |
|
|
|
/// The requested number of frames per buffer callback (advisory; AVAudioEngine may vary). |
|
public let bufferSize: AVAudioFrameCount |
|
|
|
/// The format of buffers delivered to the delegate. |
|
public private(set) var captureFormat: AVAudioFormat? |
|
|
|
/// `true` while audio capture is active. |
|
public var isRunning: Bool { engine?.isRunning ?? false } |
|
|
|
// MARK: - Private properties |
|
|
|
private var engine: AVAudioEngine? |
|
private var inputNode: AVAudioInputNode? { engine?.inputNode } |
|
|
|
// MARK: - Init |
|
|
|
/// - Parameters: |
|
/// - sampleRate: Desired sample rate in Hz (default: 44100). The engine will |
|
/// configure a matching format; if the hardware cannot satisfy the rate it falls |
|
/// back to the hardware's native rate. |
|
/// - bufferSize: Advisory number of frames per callback (default: 4096). |
|
public init(sampleRate: Double = 44_100, bufferSize: AVAudioFrameCount = 4096) { |
|
self.sampleRate = sampleRate |
|
self.bufferSize = bufferSize |
|
} |
|
|
|
deinit { |
|
stopCapture() |
|
} |
|
|
|
// MARK: - Public API |
|
|
|
/// Requests microphone permission (if needed) then starts capture. |
|
/// Calls `delegate.microphoneBufferManager(_:didEncounterError:)` on failure. |
|
public func requestPermissionAndStart() { |
|
switch AVCaptureDevice.authorizationStatus(for: .audio) { |
|
case .authorized: |
|
startCapture() |
|
case .notDetermined: |
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in |
|
DispatchQueue.main.async { |
|
if granted { |
|
self?.startCapture() |
|
} else { |
|
self?.reportError(.permissionDenied) |
|
} |
|
} |
|
} |
|
default: |
|
reportError(.permissionDenied) |
|
} |
|
} |
|
|
|
/// Starts audio capture synchronously. Permission must already be granted. |
|
/// - Throws: `MicError` if setup fails. |
|
@discardableResult |
|
public func startCapture() -> Result<Void, MicError> { |
|
guard !isRunning else { return .failure(.alreadyRunning) } |
|
|
|
let newEngine = AVAudioEngine() |
|
engine = newEngine |
|
|
|
let input = newEngine.inputNode |
|
|
|
// Build a mono Float32 format at the requested sample rate. |
|
guard let format = AVAudioFormat( |
|
commonFormat: .pcmFormatFloat32, |
|
sampleRate: sampleRate, |
|
channels: 1, |
|
interleaved: false |
|
) else { |
|
let err = MicError.engineSetupFailed( |
|
underlying: NSError(domain: "MicrophoneBufferManager", |
|
code: -1, |
|
userInfo: [NSLocalizedDescriptionKey: "Could not create AVAudioFormat"]) |
|
) |
|
reportError(err) |
|
return .failure(err) |
|
} |
|
|
|
captureFormat = format |
|
|
|
// Install a tap on the input node. |
|
input.installTap(onBus: 0, |
|
bufferSize: bufferSize, |
|
format: format) { [weak self] buffer, time in |
|
guard let self else { return } |
|
self.delegate?.microphoneBufferManager(self, didReceiveBuffer: buffer, at: time) |
|
} |
|
|
|
do { |
|
try newEngine.start() |
|
} catch { |
|
input.removeTap(onBus: 0) |
|
engine = nil |
|
let wrappedError = MicError.engineSetupFailed(underlying: error) |
|
reportError(wrappedError) |
|
return .failure(wrappedError) |
|
} |
|
|
|
return .success(()) |
|
} |
|
|
|
/// Stops audio capture and tears down the engine. |
|
@discardableResult |
|
public func stopCapture() -> Result<Void, MicError> { |
|
guard let engine, engine.isRunning else { return .failure(.notRunning) } |
|
engine.inputNode.removeTap(onBus: 0) |
|
engine.stop() |
|
self.engine = nil |
|
captureFormat = nil |
|
return .success(()) |
|
} |
|
|
|
// MARK: - Helpers |
|
|
|
private func reportError(_ error: MicError) { |
|
DispatchQueue.main.async { [weak self] in |
|
guard let self else { return } |
|
self.delegate?.microphoneBufferManager(self, didEncounterError: error) |
|
} |
|
} |
|
} |
|
|
|
// MARK: - Convenience: copy samples into a plain [Float] array |
|
|
|
public extension AVAudioPCMBuffer { |
|
/// Returns a flat `[Float]` copy of channel 0 (or the first available channel). |
|
var monoSamples: [Float] { |
|
guard let channelData = floatChannelData else { return [] } |
|
let frameCount = Int(frameLength) |
|
return Array(UnsafeBufferPointer(start: channelData[0], count: frameCount)) |
|
} |
|
} |