Fix audio format changes and thread safety issues

This commit is contained in:
Beingpax 2025-12-12 14:32:20 +05:45
parent 42f4b93ff7
commit 4f4837d310

View File

@ -27,12 +27,32 @@ class AudioEngineRecorder: ObservableObject {
private let fileWriteLock = NSLock() private let fileWriteLock = NSLock()
init() { init() {
logger.info("AudioEngineRecorder initialized") setupNotifications()
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleConfigurationChange),
name: .AVAudioEngineConfigurationChange,
object: nil
)
}
@objc private func handleConfigurationChange(notification: Notification) {
Task { @MainActor in
guard isRecording else { return }
logger.info("⚠️ AVAudioEngine configuration change detected (e.g. sample rate change). Restarting engine...")
do {
try restartRecordingPreservingFile()
} catch {
logger.error("Failed to recover from configuration change: \(error.localizedDescription)")
stopRecording()
}
}
} }
func startRecording(toOutputFile url: URL) throws { func startRecording(toOutputFile url: URL) throws {
logger.info("Starting recording to: \(url.path)")
stopRecording() stopRecording()
let engine = AVAudioEngine() let engine = AVAudioEngine()
@ -43,14 +63,11 @@ class AudioEngineRecorder: ObservableObject {
let inputFormat = input.outputFormat(forBus: tapBusNumber) let inputFormat = input.outputFormat(forBus: tapBusNumber)
logger.info("Input format - Sample Rate: \(inputFormat.sampleRate), Channels: \(inputFormat.channelCount)")
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
logger.error("Invalid input format: sample rate or channel count is zero") logger.error("Invalid input format: sample rate or channel count is zero")
throw AudioEngineRecorderError.invalidInputFormat throw AudioEngineRecorderError.invalidInputFormat
} }
// 16kHz, 16-bit PCM, mono
guard let desiredFormat = AVAudioFormat( guard let desiredFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16, commonFormat: .pcmFormatInt16,
sampleRate: 16000.0, sampleRate: 16000.0,
@ -61,22 +78,20 @@ class AudioEngineRecorder: ObservableObject {
throw AudioEngineRecorderError.invalidRecordingFormat throw AudioEngineRecorderError.invalidRecordingFormat
} }
recordingFormat = desiredFormat
recordingURL = url recordingURL = url
let createdAudioFile: AVAudioFile
do { do {
if FileManager.default.fileExists(atPath: url.path) { if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} }
audioFile = try AVAudioFile( createdAudioFile = try AVAudioFile(
forWriting: url, forWriting: url,
settings: desiredFormat.settings, settings: desiredFormat.settings,
commonFormat: desiredFormat.commonFormat, commonFormat: desiredFormat.commonFormat,
interleaved: desiredFormat.isInterleaved interleaved: desiredFormat.isInterleaved
) )
logger.info("Created audio file for writing")
} catch { } catch {
logger.error("Failed to create audio file: \(error.localizedDescription)") logger.error("Failed to create audio file: \(error.localizedDescription)")
throw AudioEngineRecorderError.failedToCreateFile(error) throw AudioEngineRecorderError.failedToCreateFile(error)
@ -87,7 +102,11 @@ class AudioEngineRecorder: ObservableObject {
throw AudioEngineRecorderError.failedToCreateConverter throw AudioEngineRecorderError.failedToCreateConverter
} }
fileWriteLock.lock()
recordingFormat = desiredFormat
audioFile = createdAudioFile
converter = audioConverter converter = audioConverter
fileWriteLock.unlock()
input.installTap(onBus: tapBusNumber, bufferSize: tapBufferSize, format: inputFormat) { [weak self] (buffer, time) in input.installTap(onBus: tapBusNumber, bufferSize: tapBufferSize, format: inputFormat) { [weak self] (buffer, time) in
guard let self = self else { return } guard let self = self else { return }
@ -102,7 +121,6 @@ class AudioEngineRecorder: ObservableObject {
do { do {
try engine.start() try engine.start()
isRecording = true isRecording = true
logger.info("✅ Audio engine started successfully")
} catch { } catch {
logger.error("Failed to start audio engine: \(error.localizedDescription)") logger.error("Failed to start audio engine: \(error.localizedDescription)")
input.removeTap(onBus: tapBusNumber) input.removeTap(onBus: tapBusNumber)
@ -110,37 +128,79 @@ class AudioEngineRecorder: ObservableObject {
} }
} }
func stopRecording() { private func restartRecordingPreservingFile() throws {
logger.info("Stopping recording") if let input = inputNode {
input.removeTap(onBus: tapBusNumber)
}
audioEngine?.stop()
// Drain queue to prevent old-format buffers racing with new converter
audioProcessingQueue.sync { }
let engine = AVAudioEngine()
audioEngine = engine
let input = engine.inputNode
inputNode = input
let inputFormat = input.outputFormat(forBus: tapBusNumber)
logger.info("Restarting with new input format - Sample Rate: \(inputFormat.sampleRate)")
guard inputFormat.sampleRate > 0 else {
throw AudioEngineRecorderError.invalidInputFormat
}
guard let format = recordingFormat else {
throw AudioEngineRecorderError.invalidRecordingFormat
}
guard let newConverter = AVAudioConverter(from: inputFormat, to: format) else {
throw AudioEngineRecorderError.failedToCreateConverter
}
fileWriteLock.lock()
converter = newConverter
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()
try engine.start()
logger.info("✅ Audio engine successfully restarted after configuration change")
}
func stopRecording() {
guard isRecording else { guard isRecording else {
logger.info("Not currently recording, nothing to stop")
return return
} }
if let input = inputNode { if let input = inputNode {
input.removeTap(onBus: tapBusNumber) input.removeTap(onBus: tapBusNumber)
logger.info("Removed tap from input node")
} }
audioEngine?.stop() audioEngine?.stop()
logger.info("Audio engine stopped")
// Wait for pending buffers to finish processing before clearing resources
audioProcessingQueue.sync { }
fileWriteLock.lock() fileWriteLock.lock()
audioFile = nil audioFile = nil
converter = nil
recordingFormat = nil
fileWriteLock.unlock() fileWriteLock.unlock()
audioEngine = nil audioEngine = nil
inputNode = nil inputNode = nil
recordingFormat = nil
recordingURL = nil recordingURL = nil
converter = nil
isRecording = false isRecording = false
currentAveragePower = 0.0 currentAveragePower = 0.0
currentPeakPower = 0.0 currentPeakPower = 0.0
logger.info("✅ Recording stopped and cleaned up")
} }
nonisolated private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { nonisolated private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
@ -150,10 +210,11 @@ class AudioEngineRecorder: ObservableObject {
nonisolated private func writeBufferToFile(_ buffer: AVAudioPCMBuffer) { nonisolated private func writeBufferToFile(_ buffer: AVAudioPCMBuffer) {
fileWriteLock.lock() fileWriteLock.lock()
defer { fileWriteLock.unlock() }
guard let audioFile = audioFile, guard let audioFile = audioFile,
let converter = converter, let converter = converter,
let format = recordingFormat else { let format = recordingFormat else {
fileWriteLock.unlock()
return return
} }
@ -163,7 +224,6 @@ class AudioEngineRecorder: ObservableObject {
let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else { guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else {
fileWriteLock.unlock()
logger.error("Failed to create converted buffer") logger.error("Failed to create converted buffer")
return return
} }
@ -183,16 +243,13 @@ class AudioEngineRecorder: ObservableObject {
} }
if let error = error { if let error = error {
fileWriteLock.unlock()
logger.error("Audio conversion error: \(error.localizedDescription)") logger.error("Audio conversion error: \(error.localizedDescription)")
return return
} }
do { do {
try audioFile.write(from: convertedBuffer) try audioFile.write(from: convertedBuffer)
fileWriteLock.unlock()
} catch { } catch {
fileWriteLock.unlock()
logger.error("Failed to write buffer to file: \(error.localizedDescription)") logger.error("Failed to write buffer to file: \(error.localizedDescription)")
} }
} }
@ -222,7 +279,6 @@ class AudioEngineRecorder: ObservableObject {
let rms = sqrt(sum / Float(frameLength)) let rms = sqrt(sum / Float(frameLength))
// Convert to decibels: 20 * log10(value)
let averagePowerDb = 20.0 * log10(max(rms, 0.000001)) let averagePowerDb = 20.0 * log10(max(rms, 0.000001))
let peakPowerDb = 20.0 * log10(max(peak, 0.000001)) let peakPowerDb = 20.0 * log10(max(peak, 0.000001))
@ -241,11 +297,7 @@ class AudioEngineRecorder: ObservableObject {
} }
deinit { deinit {
// Cannot call @MainActor methods from deinit NotificationCenter.default.removeObserver(self)
if isRecording {
inputNode?.removeTap(onBus: tapBusNumber)
audioEngine?.stop()
}
} }
} }
@ -272,4 +324,4 @@ enum AudioEngineRecorderError: LocalizedError {
return "Failed to start audio engine: \(error.localizedDescription)" return "Failed to start audio engine: \(error.localizedDescription)"
} }
} }
} }