feat: add AudioCapture with AVAudioEngine mic tap and PCM conversion
This commit is contained in:
parent
590b0366d3
commit
186b6a0a7e
1 changed files with 92 additions and 0 deletions
92
MyVoxtral/MyVoxtral/Audio/AudioCapture.swift
Normal file
92
MyVoxtral/MyVoxtral/Audio/AudioCapture.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue