vOOice/VoiceInk/AudioEngineRecorder.swift
2025-12-21 21:30:08 +05:45

304 lines
10 KiB
Swift

import Foundation
@preconcurrency import AVFoundation
import CoreAudio
import os
@MainActor
class AudioEngineRecorder: ObservableObject {
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioEngineRecorder")
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
nonisolated(unsafe) private var audioFile: AVAudioFile?
nonisolated(unsafe) private var recordingFormat: AVAudioFormat?
nonisolated(unsafe) private var converter: AVAudioConverter?
private var isRecording = false
private var recordingURL: URL?
@Published var currentAveragePower: Float = -160.0
@Published var currentPeakPower: Float = -160.0
private let tapBufferSize: AVAudioFrameCount = 4096
private let tapBusNumber: AVAudioNodeBus = 0
private let audioProcessingQueue = DispatchQueue(label: "com.prakashjoshipax.VoiceInk.audioProcessing", qos: .userInitiated)
private let fileWriteLock = NSLock()
var onRecordingError: ((Error) -> Void)?
private var validationTimer: Timer?
private var hasReceivedValidBuffer = false
func startRecording(toOutputFile url: URL, retryCount: Int = 0) throws {
stopRecording()
hasReceivedValidBuffer = false
let engine = AVAudioEngine()
audioEngine = engine
let input = engine.inputNode
inputNode = input
let inputFormat = input.outputFormat(forBus: tapBusNumber)
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
logger.error("Invalid input format: sample rate or channel count is zero")
throw AudioEngineRecorderError.invalidInputFormat
}
guard let desiredFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16000.0,
channels: 1,
interleaved: false
) else {
logger.error("Failed to create desired recording format")
throw AudioEngineRecorderError.invalidRecordingFormat
}
recordingURL = url
let createdAudioFile: AVAudioFile
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
createdAudioFile = try AVAudioFile(
forWriting: url,
settings: desiredFormat.settings,
commonFormat: desiredFormat.commonFormat,
interleaved: desiredFormat.isInterleaved
)
} catch {
logger.error("Failed to create audio file: \(error.localizedDescription)")
throw AudioEngineRecorderError.failedToCreateFile(error)
}
guard let audioConverter = AVAudioConverter(from: inputFormat, to: desiredFormat) else {
logger.error("Failed to create audio format converter")
throw AudioEngineRecorderError.failedToCreateConverter
}
fileWriteLock.lock()
recordingFormat = desiredFormat
audioFile = createdAudioFile
converter = audioConverter
fileWriteLock.unlock()
input.installTap(onBus: tapBusNumber, bufferSize: tapBufferSize, format: inputFormat) { [weak self] (buffer, time) in
guard let self = self else { return }
self.audioProcessingQueue.async {
self.processAudioBuffer(buffer)
}
}
engine.prepare()
do {
try engine.start()
isRecording = true
startValidationTimer(url: url, retryCount: retryCount)
} catch {
logger.error("Failed to start audio engine: \(error.localizedDescription)")
input.removeTap(onBus: tapBusNumber)
throw AudioEngineRecorderError.failedToStartEngine(error)
}
}
private func startValidationTimer(url: URL, retryCount: Int) {
validationTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
guard let self = self else { return }
let validationPassed = self.hasReceivedValidBuffer
if !validationPassed {
self.logger.warning("Recording validation failed")
self.stopRecording()
if retryCount < 2 {
self.logger.info("Retrying recording (attempt \(retryCount + 1)/2)...")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
do {
try self.startRecording(toOutputFile: url, retryCount: retryCount + 1)
} catch {
self.logger.error("Retry failed: \(error.localizedDescription)")
self.onRecordingError?(error)
}
}
} else {
self.logger.error("Recording failed after 2 retry attempts")
self.onRecordingError?(AudioEngineRecorderError.recordingValidationFailed)
}
} else {
self.logger.info("Recording validation successful")
}
}
}
func stopRecording() {
guard isRecording else { return }
validationTimer?.invalidate()
validationTimer = nil
inputNode?.removeTap(onBus: tapBusNumber)
audioEngine?.stop()
audioProcessingQueue.sync { }
fileWriteLock.lock()
audioFile = nil
converter = nil
recordingFormat = nil
fileWriteLock.unlock()
audioEngine = nil
inputNode = nil
recordingURL = nil
isRecording = false
hasReceivedValidBuffer = false
currentAveragePower = 0.0
currentPeakPower = 0.0
}
nonisolated private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
updateMeters(from: buffer)
writeBufferToFile(buffer)
}
nonisolated private func writeBufferToFile(_ buffer: AVAudioPCMBuffer) {
fileWriteLock.lock()
defer { fileWriteLock.unlock() }
guard let audioFile = audioFile,
let converter = converter,
let format = recordingFormat else { return }
guard buffer.frameLength > 0 else {
logTapError(message: "Empty buffer received")
return
}
let inputSampleRate = buffer.format.sampleRate
let outputSampleRate = format.sampleRate
let ratio = outputSampleRate / inputSampleRate
let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else {
logTapError(message: "Failed to create converted buffer")
return
}
var error: NSError?
var hasProvidedBuffer = false
converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in
if hasProvidedBuffer {
outStatus.pointee = .noDataNow
return nil
} else {
hasProvidedBuffer = true
outStatus.pointee = .haveData
return buffer
}
}
if let error = error {
logTapError(message: "Audio conversion failed: \(error.localizedDescription)")
return
}
do {
try audioFile.write(from: convertedBuffer)
Task { @MainActor in
if !self.hasReceivedValidBuffer {
self.hasReceivedValidBuffer = true
}
}
} catch {
logTapError(message: "File write failed: \(error.localizedDescription)")
}
}
nonisolated private func logTapError(message: String) {
logger.error("\(message)")
}
nonisolated private func updateMeters(from buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData else { return }
let channelCount = Int(buffer.format.channelCount)
let frameLength = Int(buffer.frameLength)
guard channelCount > 0, frameLength > 0 else { return }
let channel = channelData[0]
var sum: Float = 0.0
var peak: Float = 0.0
for frame in 0..<frameLength {
let sample = channel[frame]
let absSample = abs(sample)
if absSample > peak {
peak = absSample
}
sum += sample * sample
}
let rms = sqrt(sum / Float(frameLength))
let averagePowerDb = 20.0 * log10(max(rms, 0.000001))
let peakPowerDb = 20.0 * log10(max(peak, 0.000001))
Task { @MainActor in
self.currentAveragePower = averagePowerDb
self.currentPeakPower = peakPowerDb
}
}
var isCurrentlyRecording: Bool { isRecording }
var currentRecordingURL: URL? { recordingURL }
}
// MARK: - Error Types
enum AudioEngineRecorderError: LocalizedError {
case invalidInputFormat
case invalidRecordingFormat
case failedToCreateFile(Error)
case failedToCreateConverter
case failedToStartEngine(Error)
case bufferConversionFailed
case audioConversionError(Error)
case fileWriteFailed(Error)
case recordingValidationFailed
var errorDescription: String? {
switch self {
case .invalidInputFormat:
return "Invalid audio input format from device"
case .invalidRecordingFormat:
return "Failed to create recording format"
case .failedToCreateFile(let error):
return "Failed to create audio file: \(error.localizedDescription)"
case .failedToCreateConverter:
return "Failed to create audio format converter"
case .failedToStartEngine(let error):
return "Failed to start audio engine: \(error.localizedDescription)"
case .bufferConversionFailed:
return "Failed to create buffer for audio conversion"
case .audioConversionError(let error):
return "Audio format conversion failed: \(error.localizedDescription)"
case .fileWriteFailed(let error):
return "Failed to write audio data to file: \(error.localizedDescription)"
case .recordingValidationFailed:
return "Recording failed to start - no valid audio received from device"
}
}
}