From 2944e4ce5c3f1a5677e7d5d27ad0e0382e673179 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 13:04:35 +0545 Subject: [PATCH 1/9] Improve recorder device change handling --- VoiceInk/AudioEngineRecorder.swift | 76 ---------------------- VoiceInk/Recorder.swift | 19 ++---- VoiceInk/Services/AudioDeviceManager.swift | 4 +- 3 files changed, 8 insertions(+), 91 deletions(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index d3f596b..bdb0d0e 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -29,32 +29,6 @@ class AudioEngineRecorder: ObservableObject { // Callback to notify parent class of runtime recording errors var onRecordingError: ((Error) -> Void)? - init() { - 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 { stopRecording() @@ -131,52 +105,6 @@ class AudioEngineRecorder: ObservableObject { } } - private func restartRecordingPreservingFile() throws { - 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 { return @@ -307,10 +235,6 @@ class AudioEngineRecorder: ObservableObject { var currentRecordingURL: URL? { return recordingURL } - - deinit { - NotificationCenter.default.removeObserver(self) - } } // MARK: - Error Types diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index 15c5dde..6a3cf01 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -36,20 +36,15 @@ class Recorder: NSObject, ObservableObject { private func handleDeviceChange() async { guard !isReconfiguring else { return } + isReconfiguring = true - - if recorder != nil { - let currentURL = recorder?.currentRecordingURL - stopRecording() - - if let url = currentURL { - do { - try await startRecording(toOutputFile: url) - } catch { - logger.error("❌ Failed to restart recording after device change: \(error.localizedDescription)") - } - } + + try? await Task.sleep(nanoseconds: 200_000_000) + + await MainActor.run { + NotificationCenter.default.post(name: .toggleMiniRecorder, object: nil) } + isReconfiguring = false } diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index 33ea497..4ca0442 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -526,8 +526,6 @@ class AudioDeviceManager: ObservableObject { } private func notifyDeviceChange() { - if !isRecordingActive { - NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil) - } + NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil) } } From d7504412c98ef04167e5803fe50c25c6fe7c68c8 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 13:22:15 +0545 Subject: [PATCH 2/9] Only trigger device change when recording is active --- VoiceInk/Recorder.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index 6a3cf01..9a72973 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -36,6 +36,7 @@ class Recorder: NSObject, ObservableObject { private func handleDeviceChange() async { guard !isReconfiguring else { return } + guard recorder != nil else { return } isReconfiguring = true From 873379c0ca85604255bcd7fde947f67736ea5d14 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 19:37:03 +0545 Subject: [PATCH 3/9] Improve audio recording startup with validation, retry logic, and device change handling --- VoiceInk/AudioEngineRecorder.swift | 90 +++++++++++++------ VoiceInk/Recorder.swift | 12 ++- .../Services/AudioDeviceConfiguration.swift | 7 +- VoiceInk/Services/AudioDeviceManager.swift | 17 ++-- 4 files changed, 77 insertions(+), 49 deletions(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index bdb0d0e..b8e4249 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -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" } } } \ No newline at end of file diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index 9a72973..b1f8d7c 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -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 diff --git a/VoiceInk/Services/AudioDeviceConfiguration.swift b/VoiceInk/Services/AudioDeviceConfiguration.swift index 4bc235e..0ff54f2 100644 --- a/VoiceInk/Services/AudioDeviceConfiguration.swift +++ b/VoiceInk/Services/AudioDeviceConfiguration.swift @@ -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.size) var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain + mElement: kAudioObjectPropertyElementMaster ) let setDeviceResult = AudioObjectSetPropertyData( diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index 4ca0442..d2a9a20 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -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.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, From 3306c6a6e40c16a21995a5d00875c51baf831e84 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 20:28:14 +0545 Subject: [PATCH 4/9] fix(audio): Reduce recording validation timer for faster feedback --- VoiceInk/AudioEngineRecorder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index b8e4249..7b4335c 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -110,7 +110,7 @@ class AudioEngineRecorder: ObservableObject { } private func startValidationTimer(url: URL, retryCount: Int) { - validationTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in + validationTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in guard let self = self else { return } let validationPassed = self.hasReceivedValidBuffer From c57c729d6c096190bd26c8a31247411bb90d2a20 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 21:30:08 +0545 Subject: [PATCH 5/9] init power meters to silence --- VoiceInk/AudioEngineRecorder.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index 7b4335c..bb608f3 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -17,8 +17,8 @@ class AudioEngineRecorder: ObservableObject { private var isRecording = false private var recordingURL: URL? - @Published var currentAveragePower: Float = 0.0 - @Published var currentPeakPower: Float = 0.0 + @Published var currentAveragePower: Float = -160.0 + @Published var currentPeakPower: Float = -160.0 private let tapBufferSize: AVAudioFrameCount = 4096 private let tapBusNumber: AVAudioNodeBus = 0 From 621f99c10f9eef16e4abcc0ff0c1dbf72932bfc6 Mon Sep 17 00:00:00 2001 From: Prakash Joshi Pax <101010368+Beingpax@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:41:57 +0545 Subject: [PATCH 6/9] Update VoiceInk/Services/AudioDeviceConfiguration.swift Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- VoiceInk/Services/AudioDeviceConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VoiceInk/Services/AudioDeviceConfiguration.swift b/VoiceInk/Services/AudioDeviceConfiguration.swift index 0ff54f2..44aa7d3 100644 --- a/VoiceInk/Services/AudioDeviceConfiguration.swift +++ b/VoiceInk/Services/AudioDeviceConfiguration.swift @@ -15,7 +15,7 @@ class AudioDeviceConfiguration { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster + mElement: kAudioObjectPropertyElementMain ) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), From 4cef5aa12dcdb5d73674aea6f84eed40126bbd7a Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 21:45:07 +0545 Subject: [PATCH 7/9] Fix: Replace deprecated kAudioObjectPropertyElementMaster with kAudioObjectPropertyElementMain --- VoiceInk/Services/AudioDeviceConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VoiceInk/Services/AudioDeviceConfiguration.swift b/VoiceInk/Services/AudioDeviceConfiguration.swift index 44aa7d3..02e0cb6 100644 --- a/VoiceInk/Services/AudioDeviceConfiguration.swift +++ b/VoiceInk/Services/AudioDeviceConfiguration.swift @@ -38,7 +38,7 @@ class AudioDeviceConfiguration { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMaster + mElement: kAudioObjectPropertyElementMain ) let setDeviceResult = AudioObjectSetPropertyData( From 0445dca865d0776c5ffe7efe502b001c151d37ea Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 21:50:35 +0545 Subject: [PATCH 8/9] Fix: Allow device switching during recording on disconnection --- VoiceInk/Services/AudioDeviceManager.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index d2a9a20..85617d9 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -447,13 +447,6 @@ 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 } From 003e0d82056a0e4bf8a927d38816a0886864935c Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sun, 21 Dec 2025 22:05:55 +0545 Subject: [PATCH 9/9] Revert "Fix: Allow device switching during recording on disconnection" This reverts commit 0445dca865d0776c5ffe7efe502b001c151d37ea. --- VoiceInk/Services/AudioDeviceManager.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index 85617d9..d2a9a20 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -447,6 +447,13 @@ 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 }