Fix audio format changes and thread safety issues
This commit is contained in:
parent
42f4b93ff7
commit
4f4837d310
@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user