Add AV Audio Engine recorder setup
This commit is contained in:
parent
baae439aae
commit
8f48c91642
268
VoiceInk/AudioEngineRecorder.swift
Normal file
268
VoiceInk/AudioEngineRecorder.swift
Normal file
@ -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..<frameLength {
|
||||
let sample = channel[frame]
|
||||
let absSample = abs(sample)
|
||||
|
||||
if absSample > 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user