Improve audio recording startup with validation, retry logic, and device change handling

This commit is contained in:
Beingpax 2025-12-21 19:37:03 +05:45
parent d7504412c9
commit 873379c0ca
4 changed files with 77 additions and 49 deletions

View File

@ -26,11 +26,14 @@ class AudioEngineRecorder: ObservableObject {
private let audioProcessingQueue = DispatchQueue(label: "com.prakashjoshipax.VoiceInk.audioProcessing", qos: .userInitiated)
private let fileWriteLock = NSLock()
// Callback to notify parent class of runtime recording errors
var onRecordingError: ((Error) -> Void)?
func startRecording(toOutputFile url: URL) throws {
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
@ -98,6 +101,7 @@ class AudioEngineRecorder: ObservableObject {
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)
@ -105,18 +109,44 @@ class AudioEngineRecorder: ObservableObject {
}
}
private func startValidationTimer(url: URL, retryCount: Int) {
validationTimer = Timer.scheduledTimer(withTimeInterval: 3.0, 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
}
guard isRecording else { return }
if let input = inputNode {
input.removeTap(onBus: tapBusNumber)
}
validationTimer?.invalidate()
validationTimer = nil
inputNode?.removeTap(onBus: tapBusNumber)
audioEngine?.stop()
// Wait for pending buffers to finish processing before clearing resources
audioProcessingQueue.sync { }
fileWriteLock.lock()
@ -129,6 +159,7 @@ class AudioEngineRecorder: ObservableObject {
inputNode = nil
recordingURL = nil
isRecording = false
hasReceivedValidBuffer = false
currentAveragePower = 0.0
currentPeakPower = 0.0
@ -145,7 +176,10 @@ class AudioEngineRecorder: ObservableObject {
guard let audioFile = audioFile,
let converter = converter,
let format = recordingFormat else {
let format = recordingFormat else { return }
guard buffer.frameLength > 0 else {
logTapError(message: "Empty buffer received")
return
}
@ -155,10 +189,7 @@ class AudioEngineRecorder: ObservableObject {
let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else {
logger.error("Failed to create converted buffer")
Task { @MainActor in
self.onRecordingError?(AudioEngineRecorderError.bufferConversionFailed)
}
logTapError(message: "Failed to create converted buffer")
return
}
@ -177,23 +208,26 @@ class AudioEngineRecorder: ObservableObject {
}
if let error = error {
logger.error("Audio conversion error: \(error.localizedDescription)")
Task { @MainActor in
self.onRecordingError?(AudioEngineRecorderError.audioConversionError(error))
}
logTapError(message: "Audio conversion failed: \(error.localizedDescription)")
return
}
do {
try audioFile.write(from: convertedBuffer)
} catch {
logger.error("Failed to write buffer to file: \(error.localizedDescription)")
Task { @MainActor in
self.onRecordingError?(AudioEngineRecorderError.fileWriteFailed(error))
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 }
@ -228,13 +262,8 @@ class AudioEngineRecorder: ObservableObject {
}
}
var isCurrentlyRecording: Bool {
return isRecording
}
var currentRecordingURL: URL? {
return recordingURL
}
var isCurrentlyRecording: Bool { isRecording }
var currentRecordingURL: URL? { recordingURL }
}
// MARK: - Error Types
@ -248,6 +277,7 @@ enum AudioEngineRecorderError: LocalizedError {
case bufferConversionFailed
case audioConversionError(Error)
case fileWriteFailed(Error)
case recordingValidationFailed
var errorDescription: String? {
switch self {
@ -267,6 +297,8 @@ enum AudioEngineRecorderError: LocalizedError {
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"
}
}
}

View File

@ -74,14 +74,12 @@ class Recorder: NSObject, ObservableObject {
hasDetectedAudioInCurrentSession = false
let deviceID = deviceManager.getCurrentDevice()
if deviceID != 0 {
do {
try await configureAudioSession(with: deviceID)
} catch {
logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)")
}
do {
try await configureAudioSession(with: deviceID)
} catch {
logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)")
}
do {
let engineRecorder = AudioEngineRecorder()
recorder = engineRecorder

View File

@ -15,7 +15,7 @@ class AudioDeviceConfiguration {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
mElement: kAudioObjectPropertyElementMaster
)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
@ -33,15 +33,12 @@ class AudioDeviceConfiguration {
}
static func setDefaultInputDevice(_ deviceID: AudioDeviceID) throws {
if let currentDefault = getDefaultInputDevice(), currentDefault == deviceID {
return
}
var deviceIDCopy = deviceID
let propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
mElement: kAudioObjectPropertyElementMaster
)
let setDeviceResult = AudioObjectSetPropertyData(

View File

@ -142,7 +142,6 @@ class AudioDeviceManager: ObservableObject {
}
func loadAvailableDevices(completion: (() -> Void)? = nil) {
logger.info("Loading available audio devices...")
var propertySize: UInt32 = 0
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
@ -159,7 +158,6 @@ class AudioDeviceManager: ObservableObject {
)
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
logger.info("Found \(deviceCount) total audio devices")
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
@ -186,11 +184,6 @@ class AudioDeviceManager: ObservableObject {
return (id: deviceID, uid: uid, name: name)
}
logger.info("Found \(devices.count) input devices")
devices.forEach { device in
logger.info("Available device: \(device.name) (ID: \(device.id))")
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.availableDevices = devices.map { ($0.id, $0.uid, $0.name) }
@ -453,9 +446,17 @@ class AudioDeviceManager: ObservableObject {
private func handleDeviceListChange() {
logger.info("Device list change detected")
// Don't change devices while recording is active
// This prevents audio engine errors during recording startup
if isRecordingActive {
logger.info("Recording is active - deferring device change handling")
return
}
loadAvailableDevices { [weak self] in
guard let self = self else { return }
if self.inputMode == .prioritized {
self.selectHighestPriorityAvailableDevice()
} else if self.inputMode == .custom,