From 8f48c91642523b3c075900f3ba49a9cc2cffe546 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Thu, 11 Dec 2025 21:37:31 +0545 Subject: [PATCH 1/7] Add AV Audio Engine recorder setup --- VoiceInk/AudioEngineRecorder.swift | 268 +++++++++++++++++++++++++++++ VoiceInk/Recorder.swift | 95 ++++------ 2 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 VoiceInk/AudioEngineRecorder.swift diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift new file mode 100644 index 0000000..e3a27ba --- /dev/null +++ b/VoiceInk/AudioEngineRecorder.swift @@ -0,0 +1,268 @@ +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 = 0.0 + @Published var currentPeakPower: Float = 0.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() + + init() { + logger.info("AudioEngineRecorder initialized") + } + + func startRecording(toOutputFile url: URL) throws { + logger.info("Starting recording to: \(url.path)") + + stopRecording() + + let engine = AVAudioEngine() + audioEngine = engine + + let input = engine.inputNode + inputNode = input + + 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 { + logger.error("Invalid input format: sample rate or channel count is zero") + throw AudioEngineRecorderError.invalidInputFormat + } + + // 16kHz, 16-bit PCM, mono + 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 + } + + recordingFormat = desiredFormat + recordingURL = url + + do { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + + audioFile = try AVAudioFile( + forWriting: url, + settings: desiredFormat.settings, + commonFormat: desiredFormat.commonFormat, + interleaved: desiredFormat.isInterleaved + ) + + logger.info("Created audio file for writing") + } 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 + } + + converter = audioConverter + + 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 + logger.info("✅ Audio engine started successfully") + } catch { + logger.error("Failed to start audio engine: \(error.localizedDescription)") + input.removeTap(onBus: tapBusNumber) + throw AudioEngineRecorderError.failedToStartEngine(error) + } + } + + func stopRecording() { + logger.info("Stopping recording") + + guard isRecording else { + logger.info("Not currently recording, nothing to stop") + return + } + + if let input = inputNode { + input.removeTap(onBus: tapBusNumber) + logger.info("Removed tap from input node") + } + + audioEngine?.stop() + logger.info("Audio engine stopped") + + fileWriteLock.lock() + audioFile = nil + fileWriteLock.unlock() + + audioEngine = nil + inputNode = nil + recordingFormat = nil + recordingURL = nil + converter = nil + isRecording = false + + currentAveragePower = 0.0 + currentPeakPower = 0.0 + + logger.info("✅ Recording stopped and cleaned up") + } + + nonisolated private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { + updateMeters(from: buffer) + writeBufferToFile(buffer) + } + + nonisolated private func writeBufferToFile(_ buffer: AVAudioPCMBuffer) { + fileWriteLock.lock() + guard let audioFile = audioFile, + let converter = converter, + let format = recordingFormat else { + fileWriteLock.unlock() + 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 { + fileWriteLock.unlock() + logger.error("Failed to create converted buffer") + return + } + + var error: NSError? + + converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in + outStatus.pointee = .haveData + return buffer + } + + if let error = error { + fileWriteLock.unlock() + logger.error("Audio conversion error: \(error.localizedDescription)") + return + } + + do { + try audioFile.write(from: convertedBuffer) + fileWriteLock.unlock() + } catch { + fileWriteLock.unlock() + logger.error("Failed to write buffer to file: \(error.localizedDescription)") + } + } + + 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.. peak { + peak = absSample + } + + sum += sample * sample + } + + let rms = sqrt(sum / Float(frameLength)) + + // Convert to decibels: 20 * log10(value) + 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 { + return isRecording + } + + var currentRecordingURL: URL? { + return recordingURL + } + + deinit { + // Cannot call @MainActor methods from deinit + if isRecording { + inputNode?.removeTap(onBus: tapBusNumber) + audioEngine?.stop() + } + } +} + +// MARK: - Error Types + +enum AudioEngineRecorderError: LocalizedError { + case invalidInputFormat + case invalidRecordingFormat + case failedToCreateFile(Error) + case failedToCreateConverter + case failedToStartEngine(Error) + + 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)" + } + } +} diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index b683f7b..4c7f9ff 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -4,8 +4,8 @@ import CoreAudio import os @MainActor -class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { - private var recorder: AVAudioRecorder? +class Recorder: NSObject, ObservableObject { + private var recorder: AudioEngineRecorder? private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Recorder") private let deviceManager = AudioDeviceManager.shared private var deviceObserver: NSObjectProtocol? @@ -37,11 +37,11 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { private func handleDeviceChange() async { guard !isReconfiguring else { return } isReconfiguring = true - + if recorder != nil { - let currentURL = recorder?.url + let currentURL = recorder?.currentRecordingURL stopRecording() - + if let url = currentURL { do { try await startRecording(toOutputFile: url) @@ -86,42 +86,30 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { } } - let recordSettings: [String: Any] = [ - AVFormatIDKey: Int(kAudioFormatLinearPCM), - AVSampleRateKey: 16000.0, - AVNumberOfChannelsKey: 1, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - AVLinearPCMIsNonInterleaved: false - ] - do { - recorder = try AVAudioRecorder(url: url, settings: recordSettings) - recorder?.delegate = self - recorder?.isMeteringEnabled = true - - if recorder?.record() == false { - logger.error("❌ Could not start recording") - throw RecorderError.couldNotStartRecording - } - + let engineRecorder = AudioEngineRecorder() + recorder = engineRecorder + + try engineRecorder.startRecording(toOutputFile: url) + + logger.info("✅ AudioEngineRecorder started successfully") + Task { [weak self] in guard let self = self else { return } await self.playbackController.pauseMedia() _ = await self.mediaController.muteSystemAudio() } - + audioLevelCheckTask?.cancel() audioMeterUpdateTask?.cancel() - + audioMeterUpdateTask = Task { while recorder != nil && !Task.isCancelled { updateAudioMeter() - try? await Task.sleep(nanoseconds: 33_000_000) + try? await Task.sleep(nanoseconds: 17_000_000) } } - + audioLevelCheckTask = Task { let notificationChecks: [TimeInterval] = [5.0, 12.0] @@ -142,7 +130,7 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { } } } - + } catch { logger.error("Failed to create audio recorder: \(error.localizedDescription)") stopRecording() @@ -153,10 +141,10 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { func stopRecording() { audioLevelCheckTask?.cancel() audioMeterUpdateTask?.cancel() - recorder?.stop() + recorder?.stopRecording() recorder = nil audioMeter = AudioMeter(averagePower: 0, peakPower: 0) - + Task { await mediaController.unmuteSystemAudio() try? await Task.sleep(nanoseconds: 100_000_000) @@ -167,12 +155,11 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { private func updateAudioMeter() { guard let recorder = recorder else { return } - recorder.updateMeters() - - let averagePower = recorder.averagePower(forChannel: 0) - let peakPower = recorder.peakPower(forChannel: 0) - - let minVisibleDb: Float = -60.0 + + let averagePower = recorder.currentAveragePower + let peakPower = recorder.currentPeakPower + + let minVisibleDb: Float = -60.0 let maxVisibleDb: Float = 0.0 let normalizedAverage: Float @@ -183,7 +170,7 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { } else { normalizedAverage = (averagePower - minVisibleDb) / (maxVisibleDb - minVisibleDb) } - + let normalizedPeak: Float if peakPower < minVisibleDb { normalizedPeak = 0.0 @@ -192,42 +179,18 @@ class Recorder: NSObject, ObservableObject, AVAudioRecorderDelegate { } else { normalizedPeak = (peakPower - minVisibleDb) / (maxVisibleDb - minVisibleDb) } - + let newAudioMeter = AudioMeter(averagePower: Double(normalizedAverage), peakPower: Double(normalizedPeak)) if !hasDetectedAudioInCurrentSession && newAudioMeter.averagePower > 0.01 { hasDetectedAudioInCurrentSession = true } - + audioMeter = newAudioMeter } - // MARK: - AVAudioRecorderDelegate - - nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { - if !flag { - logger.error("❌ Recording finished unsuccessfully - file may be corrupted or empty") - Task { @MainActor in - NotificationManager.shared.showNotification( - title: "Recording failed - audio file corrupted", - type: .error - ) - } - } - } - - nonisolated func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { - if let error = error { - logger.error("❌ Recording encode error during session: \(error.localizedDescription)") - Task { @MainActor in - NotificationManager.shared.showNotification( - title: "Recording error: \(error.localizedDescription)", - type: .error - ) - } - } - } - + // MARK: - Cleanup + deinit { audioLevelCheckTask?.cancel() audioMeterUpdateTask?.cancel() From a3226bb0fb9166b106f10513227ad8386ddc9367 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 12 Dec 2025 09:19:29 +0545 Subject: [PATCH 2/7] Simplify audio input modes: keep Custom and Prioritized, remove System Default option --- VoiceInk/Services/AudioDeviceManager.swift | 75 ++++++++++++++----- .../OnboardingPermissionsView.swift | 6 +- .../Settings/AudioInputSettingsView.swift | 4 +- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index 0bfd5f9..c5f684d 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -10,7 +10,6 @@ struct PrioritizedDevice: Codable, Identifiable { } enum AudioInputMode: String, CaseIterable { - case systemDefault = "System Default" case custom = "Custom Device" case prioritized = "Prioritized" } @@ -19,12 +18,12 @@ class AudioDeviceManager: ObservableObject { private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioDeviceManager") @Published var availableDevices: [(id: AudioDeviceID, uid: String, name: String)] = [] @Published var selectedDeviceID: AudioDeviceID? - @Published var inputMode: AudioInputMode = .systemDefault + @Published var inputMode: AudioInputMode = .custom @Published var prioritizedDevices: [PrioritizedDevice] = [] var fallbackDeviceID: AudioDeviceID? - + var isRecordingActive: Bool = false - + static let shared = AudioDeviceManager() init() { @@ -33,14 +32,35 @@ class AudioDeviceManager: ObservableObject { loadAvailableDevices { [weak self] in self?.initializeSelectedDevice() } - + + migrateFromSystemDefaultIfNeeded() + if let savedMode = UserDefaults.standard.audioInputModeRawValue, let mode = AudioInputMode(rawValue: savedMode) { inputMode = mode + } else { + inputMode = .custom } - + setupDeviceChangeNotifications() } + + private func migrateFromSystemDefaultIfNeeded() { + if let savedModeRaw = UserDefaults.standard.audioInputModeRawValue, + savedModeRaw == "System Default" { + logger.info("Migrating from System Default mode to Custom mode") + + if let fallbackID = fallbackDeviceID { + selectedDeviceID = fallbackID + if let device = availableDevices.first(where: { $0.id == fallbackID }) { + UserDefaults.standard.selectedAudioDeviceUID = device.uid + logger.info("Migrated to Custom mode with device: \(device.name)") + } + } + + UserDefaults.standard.audioInputModeRawValue = AudioInputMode.custom.rawValue + } + } func setupFallbackDevice() { let deviceID: AudioDeviceID? = getDeviceProperty( @@ -216,6 +236,14 @@ class AudioDeviceManager: ObservableObject { self.selectedDeviceID = id UserDefaults.standard.selectedAudioDeviceUID = uid self.logger.info("Device selection saved with UID: \(uid)") + + do { + try AudioDeviceConfiguration.setDefaultInputDevice(id) + self.logger.info("✅ Set device as system default immediately") + } catch { + self.logger.error("Failed to set device as system default: \(error.localizedDescription)") + } + self.notifyDeviceChange() } } else { @@ -232,6 +260,14 @@ class AudioDeviceManager: ObservableObject { self.selectedDeviceID = id UserDefaults.standard.audioInputModeRawValue = AudioInputMode.custom.rawValue UserDefaults.standard.selectedAudioDeviceUID = uid + + do { + try AudioDeviceConfiguration.setDefaultInputDevice(id) + self.logger.info("✅ Set device as system default immediately") + } catch { + self.logger.error("Failed to set device as system default: \(error.localizedDescription)") + } + self.notifyDeviceChange() } } else { @@ -243,11 +279,8 @@ class AudioDeviceManager: ObservableObject { func selectInputMode(_ mode: AudioInputMode) { inputMode = mode UserDefaults.standard.audioInputModeRawValue = mode.rawValue - - if mode == .systemDefault { - selectedDeviceID = nil - UserDefaults.standard.removeObject(forKey: UserDefaults.Keys.selectedAudioDeviceUID) - } else if selectedDeviceID == nil { + + if selectedDeviceID == nil { if inputMode == .custom { if let firstDevice = availableDevices.first { selectDevice(id: firstDevice.id) @@ -255,15 +288,22 @@ class AudioDeviceManager: ObservableObject { } else if inputMode == .prioritized { selectHighestPriorityAvailableDevice() } + } else { + if let currentDeviceID = selectedDeviceID { + do { + try AudioDeviceConfiguration.setDefaultInputDevice(currentDeviceID) + logger.info("✅ Set current device as system default when mode changed") + } catch { + logger.error("Failed to set device as system default: \(error.localizedDescription)") + } + } } - + notifyDeviceChange() } func getCurrentDevice() -> AudioDeviceID { switch inputMode { - case .systemDefault: - return fallbackDeviceID ?? 0 case .custom: if let id = selectedDeviceID, isDeviceAvailable(id) { return id @@ -333,14 +373,15 @@ class AudioDeviceManager: ObservableObject { private func selectHighestPriorityAvailableDevice() { let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority } - + for device in sortedDevices { if let availableDevice = availableDevices.first(where: { $0.uid == device.id }) { selectedDeviceID = availableDevice.id logger.info("Selected prioritized device: \(device.name) (Priority: \(device.priority))") - + do { try AudioDeviceConfiguration.setDefaultInputDevice(availableDevice.id) + logger.info("✅ Set prioritized device as system default immediately") } catch { logger.error("Failed to set prioritized device: \(error.localizedDescription)") continue @@ -349,7 +390,7 @@ class AudioDeviceManager: ObservableObject { return } } - + fallbackToDefaultDevice() } diff --git a/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift b/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift index 1415f03..f753e3c 100644 --- a/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift +++ b/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift @@ -278,8 +278,8 @@ struct OnboardingPermissionsView: View { // Check microphone permission permissionStates[0] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - // Check if device is selected or system default mode is being used - permissionStates[1] = audioDeviceManager.selectedDeviceID != nil || audioDeviceManager.inputMode == .systemDefault + // Check if device is selected + permissionStates[1] = audioDeviceManager.selectedDeviceID != nil // Check accessibility permission permissionStates[2] = AXIsProcessTrusted() @@ -315,7 +315,7 @@ struct OnboardingPermissionsView: View { audioDeviceManager.loadAvailableDevices() if audioDeviceManager.availableDevices.isEmpty { - audioDeviceManager.selectInputMode(.systemDefault) + audioDeviceManager.selectInputMode(.custom) withAnimation { permissionStates[currentPermissionIndex] = true showAnimation = true diff --git a/VoiceInk/Views/Settings/AudioInputSettingsView.swift b/VoiceInk/Views/Settings/AudioInputSettingsView.swift index 226e307..ed3135c 100644 --- a/VoiceInk/Views/Settings/AudioInputSettingsView.swift +++ b/VoiceInk/Views/Settings/AudioInputSettingsView.swift @@ -258,15 +258,13 @@ struct InputModeCard: View { private var icon: String { switch mode { - case .systemDefault: return "macbook.and.iphone" case .custom: return "mic.circle.fill" case .prioritized: return "list.number" } } - + private var description: String { switch mode { - case .systemDefault: return "Use system's default input device" case .custom: return "Select a specific input device" case .prioritized: return "Set up device priority order" } From bd6973e5599e585c0c7c089db265dd9fdbc80efc Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 12 Dec 2025 11:39:25 +0545 Subject: [PATCH 3/7] Better device fallback logic --- VoiceInk/Services/AudioDeviceManager.swift | 38 ++++++++++++++++--- .../OnboardingPermissionsView.swift | 38 ++++++------------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index c5f684d..d31007b 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -106,13 +106,39 @@ class AudioDeviceManager: ObservableObject { } private func fallbackToDefaultDevice() { - logger.info("Temporarily falling back to system default input device – user preference remains intact.") + logger.info("Current device unavailable, selecting new device...") - if let currentID = selectedDeviceID, !isDeviceAvailable(currentID) { + guard let newDeviceID = findBestAvailableDevice() else { + logger.error("No input devices available!") selectedDeviceID = nil + notifyDeviceChange() + return } - notifyDeviceChange() + let newDeviceName = getDeviceName(deviceID: newDeviceID) ?? "Unknown Device" + logger.info("Auto-selecting new device: \(newDeviceName)") + selectDevice(id: newDeviceID) + } + + func findBestAvailableDevice() -> AudioDeviceID? { + if let device = availableDevices.first(where: { isBuiltInDevice($0.id) }) { + logger.info("Found built-in device: \(device.name)") + return device.id + } + + if let device = availableDevices.first { + logger.warning("No built-in device found, using first available: \(device.name)") + return device.id + } + + return nil + } + + private func isBuiltInDevice(_ deviceID: AudioDeviceID) -> Bool { + guard let uid = getDeviceUID(deviceID: deviceID) else { + return false + } + return uid.contains("BuiltIn") } func loadAvailableDevices(completion: (() -> Void)? = nil) { @@ -308,7 +334,8 @@ class AudioDeviceManager: ObservableObject { if let id = selectedDeviceID, isDeviceAvailable(id) { return id } else { - return fallbackDeviceID ?? 0 + // Use smart device finding instead of stale fallback + return findBestAvailableDevice() ?? 0 } case .prioritized: let sortedDevices = prioritizedDevices.sorted { $0.priority < $1.priority } @@ -317,7 +344,8 @@ class AudioDeviceManager: ObservableObject { return available.id } } - return fallbackDeviceID ?? 0 + // Use smart device finding instead of stale fallback + return findBestAvailableDevice() ?? 0 } } diff --git a/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift b/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift index f753e3c..b5be453 100644 --- a/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift +++ b/VoiceInk/Views/Onboarding/OnboardingPermissionsView.swift @@ -175,15 +175,9 @@ struct OnboardingPermissionsView: View { } ) .onAppear { - // Auto-select built-in microphone if no device is selected - if audioDeviceManager.selectedDeviceID == nil && !audioDeviceManager.availableDevices.isEmpty { - let builtInDevice = audioDeviceManager.availableDevices.first { device in - device.name.lowercased().contains("built-in") || - device.name.lowercased().contains("internal") - } - let deviceToSelect = builtInDevice ?? audioDeviceManager.availableDevices.first - if let device = deviceToSelect { - audioDeviceManager.selectDevice(id: device.id) + if !audioDeviceManager.availableDevices.isEmpty { + if let deviceID = audioDeviceManager.findBestAvailableDevice() { + audioDeviceManager.selectDevice(id: deviceID) audioDeviceManager.selectInputMode(.custom) withAnimation { permissionStates[currentPermissionIndex] = true @@ -313,7 +307,7 @@ struct OnboardingPermissionsView: View { case .audioDeviceSelection: audioDeviceManager.loadAvailableDevices() - + if audioDeviceManager.availableDevices.isEmpty { audioDeviceManager.selectInputMode(.custom) withAnimation { @@ -323,23 +317,13 @@ struct OnboardingPermissionsView: View { moveToNext() return } - - // If no device is selected yet, auto-select the built-in microphone or first available device - if audioDeviceManager.selectedDeviceID == nil { - let builtInDevice = audioDeviceManager.availableDevices.first { device in - device.name.lowercased().contains("built-in") || - device.name.lowercased().contains("internal") - } - - let deviceToSelect = builtInDevice ?? audioDeviceManager.availableDevices.first - - if let device = deviceToSelect { - audioDeviceManager.selectDevice(id: device.id) - audioDeviceManager.selectInputMode(.custom) - withAnimation { - permissionStates[currentPermissionIndex] = true - showAnimation = true - } + + if let deviceID = audioDeviceManager.findBestAvailableDevice() { + audioDeviceManager.selectDevice(id: deviceID) + audioDeviceManager.selectInputMode(.custom) + withAnimation { + permissionStates[currentPermissionIndex] = true + showAnimation = true } } moveToNext() From 42f4b93ff7a0783895fda3b0f68fa1b80b5bcc1b Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 12 Dec 2025 13:09:44 +0545 Subject: [PATCH 4/7] Fix AVAudioConverter callback to prevent buffer duplication --- VoiceInk/AudioEngineRecorder.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index e3a27ba..a188631 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -169,10 +169,17 @@ class AudioEngineRecorder: ObservableObject { } var error: NSError? + var hasProvidedBuffer = false converter.convert(to: convertedBuffer, error: &error) { inNumPackets, outStatus in - outStatus.pointee = .haveData - return buffer + if hasProvidedBuffer { + outStatus.pointee = .noDataNow + return nil + } else { + hasProvidedBuffer = true + outStatus.pointee = .haveData + return buffer + } } if let error = error { From 4f4837d310180c3909bdf223a1dcb6c33e8a3619 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 12 Dec 2025 14:32:20 +0545 Subject: [PATCH 5/7] Fix audio format changes and thread safety issues --- VoiceInk/AudioEngineRecorder.swift | 116 +++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index a188631..7ca0009 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -27,12 +27,32 @@ class AudioEngineRecorder: ObservableObject { private let fileWriteLock = NSLock() 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 { - logger.info("Starting recording to: \(url.path)") - stopRecording() let engine = AVAudioEngine() @@ -43,14 +63,11 @@ class AudioEngineRecorder: ObservableObject { 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 { logger.error("Invalid input format: sample rate or channel count is zero") throw AudioEngineRecorderError.invalidInputFormat } - // 16kHz, 16-bit PCM, mono guard let desiredFormat = AVAudioFormat( commonFormat: .pcmFormatInt16, sampleRate: 16000.0, @@ -61,22 +78,20 @@ class AudioEngineRecorder: ObservableObject { throw AudioEngineRecorderError.invalidRecordingFormat } - recordingFormat = desiredFormat recordingURL = url + let createdAudioFile: AVAudioFile do { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } - audioFile = try AVAudioFile( + createdAudioFile = try AVAudioFile( forWriting: url, settings: desiredFormat.settings, commonFormat: desiredFormat.commonFormat, interleaved: desiredFormat.isInterleaved ) - - logger.info("Created audio file for writing") } catch { logger.error("Failed to create audio file: \(error.localizedDescription)") throw AudioEngineRecorderError.failedToCreateFile(error) @@ -87,7 +102,11 @@ class AudioEngineRecorder: ObservableObject { 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 } @@ -102,7 +121,6 @@ class AudioEngineRecorder: ObservableObject { do { try engine.start() isRecording = true - logger.info("✅ Audio engine started successfully") } catch { logger.error("Failed to start audio engine: \(error.localizedDescription)") input.removeTap(onBus: tapBusNumber) @@ -110,37 +128,79 @@ class AudioEngineRecorder: ObservableObject { } } - func stopRecording() { - logger.info("Stopping recording") + 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 { - logger.info("Not currently recording, nothing to stop") return } if let input = inputNode { input.removeTap(onBus: tapBusNumber) - logger.info("Removed tap from input node") } audioEngine?.stop() - logger.info("Audio engine stopped") + + // Wait for pending buffers to finish processing before clearing resources + audioProcessingQueue.sync { } fileWriteLock.lock() audioFile = nil + converter = nil + recordingFormat = nil fileWriteLock.unlock() audioEngine = nil inputNode = nil - recordingFormat = nil recordingURL = nil - converter = nil isRecording = false currentAveragePower = 0.0 currentPeakPower = 0.0 - - logger.info("✅ Recording stopped and cleaned up") } nonisolated private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { @@ -150,10 +210,11 @@ class AudioEngineRecorder: ObservableObject { nonisolated private func writeBufferToFile(_ buffer: AVAudioPCMBuffer) { fileWriteLock.lock() + defer { fileWriteLock.unlock() } + guard let audioFile = audioFile, let converter = converter, let format = recordingFormat else { - fileWriteLock.unlock() return } @@ -163,7 +224,6 @@ class AudioEngineRecorder: ObservableObject { let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else { - fileWriteLock.unlock() logger.error("Failed to create converted buffer") return } @@ -183,16 +243,13 @@ class AudioEngineRecorder: ObservableObject { } if let error = error { - fileWriteLock.unlock() logger.error("Audio conversion error: \(error.localizedDescription)") return } do { try audioFile.write(from: convertedBuffer) - fileWriteLock.unlock() } catch { - fileWriteLock.unlock() logger.error("Failed to write buffer to file: \(error.localizedDescription)") } } @@ -222,7 +279,6 @@ class AudioEngineRecorder: ObservableObject { let rms = sqrt(sum / Float(frameLength)) - // Convert to decibels: 20 * log10(value) let averagePowerDb = 20.0 * log10(max(rms, 0.000001)) let peakPowerDb = 20.0 * log10(max(peak, 0.000001)) @@ -241,11 +297,7 @@ class AudioEngineRecorder: ObservableObject { } deinit { - // Cannot call @MainActor methods from deinit - if isRecording { - inputNode?.removeTap(onBus: tapBusNumber) - audioEngine?.stop() - } + NotificationCenter.default.removeObserver(self) } } @@ -272,4 +324,4 @@ enum AudioEngineRecorderError: LocalizedError { return "Failed to start audio engine: \(error.localizedDescription)" } } -} +} \ No newline at end of file From ac8174b25853c7032f06dad261d2a90afbcb1699 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 12 Dec 2025 14:36:33 +0545 Subject: [PATCH 6/7] Fix migration logic --- VoiceInk/Services/AudioDeviceManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/VoiceInk/Services/AudioDeviceManager.swift b/VoiceInk/Services/AudioDeviceManager.swift index d31007b..33ea497 100644 --- a/VoiceInk/Services/AudioDeviceManager.swift +++ b/VoiceInk/Services/AudioDeviceManager.swift @@ -29,11 +29,6 @@ class AudioDeviceManager: ObservableObject { init() { setupFallbackDevice() loadPrioritizedDevices() - loadAvailableDevices { [weak self] in - self?.initializeSelectedDevice() - } - - migrateFromSystemDefaultIfNeeded() if let savedMode = UserDefaults.standard.audioInputModeRawValue, let mode = AudioInputMode(rawValue: savedMode) { @@ -42,6 +37,11 @@ class AudioDeviceManager: ObservableObject { inputMode = .custom } + loadAvailableDevices { [weak self] in + self?.migrateFromSystemDefaultIfNeeded() + self?.initializeSelectedDevice() + } + setupDeviceChangeNotifications() } From eb04104bcb47db2613f4e572cbcd641787f0ff64 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Fri, 12 Dec 2025 20:31:41 +0545 Subject: [PATCH 7/7] Record failure notifications --- VoiceInk/AudioEngineRecorder.swift | 21 +++++++++++++++++++++ VoiceInk/Recorder.swift | 22 ++++++++++++++++++++++ VoiceInk/Views/MenuBarView.swift | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/VoiceInk/AudioEngineRecorder.swift b/VoiceInk/AudioEngineRecorder.swift index 7ca0009..d3f596b 100644 --- a/VoiceInk/AudioEngineRecorder.swift +++ b/VoiceInk/AudioEngineRecorder.swift @@ -26,6 +26,9 @@ 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)? + init() { setupNotifications() } @@ -225,6 +228,9 @@ class AudioEngineRecorder: ObservableObject { guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else { logger.error("Failed to create converted buffer") + Task { @MainActor in + self.onRecordingError?(AudioEngineRecorderError.bufferConversionFailed) + } return } @@ -244,6 +250,9 @@ class AudioEngineRecorder: ObservableObject { if let error = error { logger.error("Audio conversion error: \(error.localizedDescription)") + Task { @MainActor in + self.onRecordingError?(AudioEngineRecorderError.audioConversionError(error)) + } return } @@ -251,6 +260,9 @@ class AudioEngineRecorder: ObservableObject { try audioFile.write(from: convertedBuffer) } catch { logger.error("Failed to write buffer to file: \(error.localizedDescription)") + Task { @MainActor in + self.onRecordingError?(AudioEngineRecorderError.fileWriteFailed(error)) + } } } @@ -309,6 +321,9 @@ enum AudioEngineRecorderError: LocalizedError { case failedToCreateFile(Error) case failedToCreateConverter case failedToStartEngine(Error) + case bufferConversionFailed + case audioConversionError(Error) + case fileWriteFailed(Error) var errorDescription: String? { switch self { @@ -322,6 +337,12 @@ enum AudioEngineRecorderError: LocalizedError { 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)" } } } \ No newline at end of file diff --git a/VoiceInk/Recorder.swift b/VoiceInk/Recorder.swift index 4c7f9ff..15c5dde 100644 --- a/VoiceInk/Recorder.swift +++ b/VoiceInk/Recorder.swift @@ -90,6 +90,13 @@ class Recorder: NSObject, ObservableObject { let engineRecorder = AudioEngineRecorder() recorder = engineRecorder + // Set up error callback to handle runtime recording failures + engineRecorder.onRecordingError = { [weak self] error in + Task { @MainActor in + await self?.handleRecordingError(error) + } + } + try engineRecorder.startRecording(toOutputFile: url) logger.info("✅ AudioEngineRecorder started successfully") @@ -153,6 +160,21 @@ class Recorder: NSObject, ObservableObject { deviceManager.isRecordingActive = false } + private func handleRecordingError(_ error: Error) async { + logger.error("❌ Recording error occurred: \(error.localizedDescription)") + + // Stop the recording + stopRecording() + + // Notify the user about the recording failure + await MainActor.run { + NotificationManager.shared.showNotification( + title: "Recording Failed: \(error.localizedDescription)", + type: .error + ) + } + } + private func updateAudioMeter() { guard let recorder = recorder else { return } diff --git a/VoiceInk/Views/MenuBarView.swift b/VoiceInk/Views/MenuBarView.swift index 61f826f..6250716 100644 --- a/VoiceInk/Views/MenuBarView.swift +++ b/VoiceInk/Views/MenuBarView.swift @@ -224,7 +224,7 @@ struct MenuBarView: View { } Divider() - + Button("Quit VoiceInk") { NSApplication.shared.terminate(nil) }