From 8f48c91642523b3c075900f3ba49a9cc2cffe546 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Thu, 11 Dec 2025 21:37:31 +0545 Subject: [PATCH] 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()