Files
2026-08-03 11:47:09 -04:00

128 lines
3.8 KiB
Swift

import AVFoundation
import Foundation
enum AudioCaptureState: Equatable {
case idle
case starting
case recording
case failed(String)
}
@MainActor
final class AudioCaptureManager: ObservableObject {
@Published private(set) var state: AudioCaptureState = .idle
var onPCMChunk: ((Data) -> Void)?
private let engine = AVAudioEngine()
private var converter: AVAudioConverter?
private var inputFormat: AVAudioFormat?
private let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16_000,
channels: 1,
interleaved: true
)!
func requestPermission() async -> Bool {
await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
func startRecording() async {
guard state != .recording && state != .starting else { return }
state = .starting
guard await requestPermission() else {
state = .failed("Microphone permission was denied.")
return
}
do {
try configureSession()
try configureEngine()
try engine.start()
state = .recording
} catch {
stopRecording()
state = .failed(error.localizedDescription)
}
}
func stopRecording() {
guard state == .recording || state == .starting else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
converter = nil
inputFormat = nil
state = .idle
}
private func configureSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
try session.setPreferredSampleRate(16_000)
try session.setPreferredIOBufferDuration(0.02)
try session.setActive(true)
}
private func configureEngine() throws {
let inputNode = engine.inputNode
let hardwareFormat = inputNode.outputFormat(forBus: 0)
inputFormat = hardwareFormat
converter = AVAudioConverter(from: hardwareFormat, to: targetFormat)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, _ in
Task { @MainActor in
guard let self else { return }
guard let data = self.convertToTargetPCM(buffer) else { return }
self.onPCMChunk?(data)
}
}
engine.prepare()
}
private func convertToTargetPCM(_ inputBuffer: AVAudioPCMBuffer) -> Data? {
guard let converter else { return nil }
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
let targetFrameCapacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio) + 1
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: targetFrameCapacity) else {
return nil
}
var didProvideInput = false
var conversionError: NSError?
converter.convert(to: outputBuffer, error: &conversionError) { _, status in
if didProvideInput {
status.pointee = .noDataNow
return nil
}
didProvideInput = true
status.pointee = .haveData
return inputBuffer
}
guard conversionError == nil else { return nil }
return outputBuffer.interleavedPCMData()
}
}
private extension AVAudioPCMBuffer {
func interleavedPCMData() -> Data? {
let audioBuffer = audioBufferList.pointee.mBuffers
guard let source = audioBuffer.mData else { return nil }
return Data(bytes: source, count: Int(audioBuffer.mDataByteSize))
}
}