Skip to content

Instantly share code, notes, and snippets.

@gabereiser
Last active March 5, 2026 20:21
Show Gist options
  • Select an option

  • Save gabereiser/cd8c67262717afd2539dc9c3d00d2c2f to your computer and use it in GitHub Desktop.

Select an option

Save gabereiser/cd8c67262717afd2539dc9c3d00d2c2f to your computer and use it in GitHub Desktop.
MicrophoneBufferManager

Here's what's included and how to use it:

Setup Add NSMicrophoneUsageDescription to your Info.plist, then:

let mic = MicrophoneBufferManager(sampleRate: 16_000, bufferSize: 4096)
mic.delegate = self
mic.requestPermissionAndStart()

Receiving buffers Conform to MicrophoneBufferManagerDelegate:

func microphoneBufferManager(_ manager: MicrophoneBufferManager,
                              didReceiveBuffer buffer: AVAudioPCMBuffer,
                              at time: AVAudioTime) {
    let samples = buffer.monoSamples  // [Float] convenience accessor
    // process samples...
}

Key design decisions:

  • AVAudioEngine + installTap — the idiomatic macOS approach; lower overhead than AudioQueue or CoreAudio directly
  • Mono Float32, non-interleaved — the most universally compatible raw format for DSP/ML pipelines
  • Permission handling built-inrequestPermissionAndStart() handles the AVCaptureDevice auth flow; you can also call startCapture() directly if permission is already granted
  • Result-returning stop/start — lets you handle errors inline or via the delegate
  • monoSamples extension — zero-copy-friendly [Float] view into channel 0 for quick access
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))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment