128 lines
3.8 KiB
Swift
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))
|
|
}
|
|
}
|