feat: add AudioCapture with AVAudioEngine mic tap and PCM conversion

This commit is contained in:
Carsten Abele 2026-04-07 19:39:16 +02:00
parent 590b0366d3
commit 186b6a0a7e

View file

@ -0,0 +1,92 @@
import AVFoundation
final class AudioCapture {
private let engine = AVAudioEngine()
private let targetSampleRate: Double = 16000
private let chunkDurationMs: Double = 480
var onChunk: ((Data) -> Void)?
private var buffer = Data()
private let bytesPerChunk: Int
init() {
// 16kHz * 2 bytes (16-bit) * 1 channel * 0.48s = 15360 bytes
bytesPerChunk = Int(targetSampleRate * 2 * chunkDurationMs / 1000)
}
func start() throws {
let inputNode = engine.inputNode
let inputFormat = inputNode.outputFormat(forBus: 0)
let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: targetSampleRate,
channels: 1,
interleaved: true
)!
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
throw AudioCaptureError.converterCreationFailed
}
let bufferSize = AVAudioFrameCount(inputFormat.sampleRate * chunkDurationMs / 1000)
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] pcmBuffer, _ in
guard let self else { return }
let frameCount = AVAudioFrameCount(
Double(pcmBuffer.frameLength) * self.targetSampleRate / inputFormat.sampleRate
)
guard let convertedBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat,
frameCapacity: frameCount
) else { return }
var error: NSError?
let status = converter.convert(to: convertedBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return pcmBuffer
}
guard status != .error, error == nil else { return }
let byteCount = Int(convertedBuffer.frameLength) * 2 // 16-bit = 2 bytes
guard let int16Ptr = convertedBuffer.int16ChannelData?[0] else { return }
let data = Data(bytes: int16Ptr, count: byteCount)
self.buffer.append(data)
while self.buffer.count >= self.bytesPerChunk {
let chunk = self.buffer.prefix(self.bytesPerChunk)
self.buffer = Data(self.buffer.dropFirst(self.bytesPerChunk))
self.onChunk?(Data(chunk))
}
}
engine.prepare()
try engine.start()
}
func stop() {
engine.inputNode.removeTap(onBus: 0)
engine.stop()
// Flush remaining buffer
if !buffer.isEmpty {
onChunk?(buffer)
buffer = Data()
}
}
}
enum AudioCaptureError: Error, LocalizedError {
case converterCreationFailed
var errorDescription: String? {
switch self {
case .converterCreationFailed:
return "Failed to create audio format converter"
}
}
}