Merge pull request #446 from Beingpax/improved-recorder-setup
Improve Audio Engine Recorder Setup
This commit is contained in:
commit
bd4b25e7c1
@ -17,8 +17,8 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
private var isRecording = false
|
private var isRecording = false
|
||||||
private var recordingURL: URL?
|
private var recordingURL: URL?
|
||||||
|
|
||||||
@Published var currentAveragePower: Float = 0.0
|
@Published var currentAveragePower: Float = -160.0
|
||||||
@Published var currentPeakPower: Float = 0.0
|
@Published var currentPeakPower: Float = -160.0
|
||||||
|
|
||||||
private let tapBufferSize: AVAudioFrameCount = 4096
|
private let tapBufferSize: AVAudioFrameCount = 4096
|
||||||
private let tapBusNumber: AVAudioNodeBus = 0
|
private let tapBusNumber: AVAudioNodeBus = 0
|
||||||
@ -26,37 +26,14 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
private let audioProcessingQueue = DispatchQueue(label: "com.prakashjoshipax.VoiceInk.audioProcessing", qos: .userInitiated)
|
private let audioProcessingQueue = DispatchQueue(label: "com.prakashjoshipax.VoiceInk.audioProcessing", qos: .userInitiated)
|
||||||
private let fileWriteLock = NSLock()
|
private let fileWriteLock = NSLock()
|
||||||
|
|
||||||
// Callback to notify parent class of runtime recording errors
|
|
||||||
var onRecordingError: ((Error) -> Void)?
|
var onRecordingError: ((Error) -> Void)?
|
||||||
|
|
||||||
init() {
|
private var validationTimer: Timer?
|
||||||
setupNotifications()
|
private var hasReceivedValidBuffer = false
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
func startRecording(toOutputFile url: URL, retryCount: Int = 0) throws {
|
||||||
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()
|
stopRecording()
|
||||||
|
hasReceivedValidBuffer = false
|
||||||
|
|
||||||
let engine = AVAudioEngine()
|
let engine = AVAudioEngine()
|
||||||
audioEngine = engine
|
audioEngine = engine
|
||||||
@ -124,6 +101,7 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try engine.start()
|
try engine.start()
|
||||||
isRecording = true
|
isRecording = true
|
||||||
|
startValidationTimer(url: url, retryCount: retryCount)
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to start audio engine: \(error.localizedDescription)")
|
logger.error("Failed to start audio engine: \(error.localizedDescription)")
|
||||||
input.removeTap(onBus: tapBusNumber)
|
input.removeTap(onBus: tapBusNumber)
|
||||||
@ -131,64 +109,44 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartRecordingPreservingFile() throws {
|
private func startValidationTimer(url: URL, retryCount: Int) {
|
||||||
if let input = inputNode {
|
validationTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
|
||||||
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 }
|
guard let self = self else { return }
|
||||||
self.audioProcessingQueue.async {
|
|
||||||
self.processAudioBuffer(buffer)
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.prepare()
|
|
||||||
try engine.start()
|
|
||||||
logger.info("✅ Audio engine successfully restarted after configuration change")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
guard isRecording else {
|
guard isRecording else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let input = inputNode {
|
validationTimer?.invalidate()
|
||||||
input.removeTap(onBus: tapBusNumber)
|
validationTimer = nil
|
||||||
}
|
|
||||||
|
|
||||||
|
inputNode?.removeTap(onBus: tapBusNumber)
|
||||||
audioEngine?.stop()
|
audioEngine?.stop()
|
||||||
|
|
||||||
// Wait for pending buffers to finish processing before clearing resources
|
|
||||||
audioProcessingQueue.sync { }
|
audioProcessingQueue.sync { }
|
||||||
|
|
||||||
fileWriteLock.lock()
|
fileWriteLock.lock()
|
||||||
@ -201,6 +159,7 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
inputNode = nil
|
inputNode = nil
|
||||||
recordingURL = nil
|
recordingURL = nil
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
hasReceivedValidBuffer = false
|
||||||
|
|
||||||
currentAveragePower = 0.0
|
currentAveragePower = 0.0
|
||||||
currentPeakPower = 0.0
|
currentPeakPower = 0.0
|
||||||
@ -217,7 +176,10 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
|
|
||||||
guard let audioFile = audioFile,
|
guard let audioFile = audioFile,
|
||||||
let converter = converter,
|
let converter = converter,
|
||||||
let format = recordingFormat else {
|
let format = recordingFormat else { return }
|
||||||
|
|
||||||
|
guard buffer.frameLength > 0 else {
|
||||||
|
logTapError(message: "Empty buffer received")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,10 +189,7 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
|
let outputCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
|
||||||
|
|
||||||
guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else {
|
guard let convertedBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputCapacity) else {
|
||||||
logger.error("Failed to create converted buffer")
|
logTapError(message: "Failed to create converted buffer")
|
||||||
Task { @MainActor in
|
|
||||||
self.onRecordingError?(AudioEngineRecorderError.bufferConversionFailed)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,23 +208,26 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
logger.error("Audio conversion error: \(error.localizedDescription)")
|
logTapError(message: "Audio conversion failed: \(error.localizedDescription)")
|
||||||
Task { @MainActor in
|
|
||||||
self.onRecordingError?(AudioEngineRecorderError.audioConversionError(error))
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try audioFile.write(from: convertedBuffer)
|
try audioFile.write(from: convertedBuffer)
|
||||||
} catch {
|
|
||||||
logger.error("Failed to write buffer to file: \(error.localizedDescription)")
|
|
||||||
Task { @MainActor in
|
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) {
|
nonisolated private func updateMeters(from buffer: AVAudioPCMBuffer) {
|
||||||
guard let channelData = buffer.floatChannelData else { return }
|
guard let channelData = buffer.floatChannelData else { return }
|
||||||
|
|
||||||
@ -300,17 +262,8 @@ class AudioEngineRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isCurrentlyRecording: Bool {
|
var isCurrentlyRecording: Bool { isRecording }
|
||||||
return isRecording
|
var currentRecordingURL: URL? { recordingURL }
|
||||||
}
|
|
||||||
|
|
||||||
var currentRecordingURL: URL? {
|
|
||||||
return recordingURL
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Types
|
// MARK: - Error Types
|
||||||
@ -324,6 +277,7 @@ enum AudioEngineRecorderError: LocalizedError {
|
|||||||
case bufferConversionFailed
|
case bufferConversionFailed
|
||||||
case audioConversionError(Error)
|
case audioConversionError(Error)
|
||||||
case fileWriteFailed(Error)
|
case fileWriteFailed(Error)
|
||||||
|
case recordingValidationFailed
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@ -343,6 +297,8 @@ enum AudioEngineRecorderError: LocalizedError {
|
|||||||
return "Audio format conversion failed: \(error.localizedDescription)"
|
return "Audio format conversion failed: \(error.localizedDescription)"
|
||||||
case .fileWriteFailed(let error):
|
case .fileWriteFailed(let error):
|
||||||
return "Failed to write audio data to file: \(error.localizedDescription)"
|
return "Failed to write audio data to file: \(error.localizedDescription)"
|
||||||
|
case .recordingValidationFailed:
|
||||||
|
return "Recording failed to start - no valid audio received from device"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,20 +36,16 @@ class Recorder: NSObject, ObservableObject {
|
|||||||
|
|
||||||
private func handleDeviceChange() async {
|
private func handleDeviceChange() async {
|
||||||
guard !isReconfiguring else { return }
|
guard !isReconfiguring else { return }
|
||||||
|
guard recorder != nil else { return }
|
||||||
|
|
||||||
isReconfiguring = true
|
isReconfiguring = true
|
||||||
|
|
||||||
if recorder != nil {
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
let currentURL = recorder?.currentRecordingURL
|
|
||||||
stopRecording()
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .toggleMiniRecorder, object: nil)
|
||||||
if let url = currentURL {
|
|
||||||
do {
|
|
||||||
try await startRecording(toOutputFile: url)
|
|
||||||
} catch {
|
|
||||||
logger.error("❌ Failed to restart recording after device change: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isReconfiguring = false
|
isReconfiguring = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,14 +74,12 @@ class Recorder: NSObject, ObservableObject {
|
|||||||
hasDetectedAudioInCurrentSession = false
|
hasDetectedAudioInCurrentSession = false
|
||||||
|
|
||||||
let deviceID = deviceManager.getCurrentDevice()
|
let deviceID = deviceManager.getCurrentDevice()
|
||||||
if deviceID != 0 {
|
do {
|
||||||
do {
|
try await configureAudioSession(with: deviceID)
|
||||||
try await configureAudioSession(with: deviceID)
|
} catch {
|
||||||
} catch {
|
logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)")
|
||||||
logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let engineRecorder = AudioEngineRecorder()
|
let engineRecorder = AudioEngineRecorder()
|
||||||
recorder = engineRecorder
|
recorder = engineRecorder
|
||||||
|
|||||||
@ -33,9 +33,6 @@ class AudioDeviceConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func setDefaultInputDevice(_ deviceID: AudioDeviceID) throws {
|
static func setDefaultInputDevice(_ deviceID: AudioDeviceID) throws {
|
||||||
if let currentDefault = getDefaultInputDevice(), currentDefault == deviceID {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var deviceIDCopy = deviceID
|
var deviceIDCopy = deviceID
|
||||||
let propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
let propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
|
|||||||
@ -142,7 +142,6 @@ class AudioDeviceManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAvailableDevices(completion: (() -> Void)? = nil) {
|
func loadAvailableDevices(completion: (() -> Void)? = nil) {
|
||||||
logger.info("Loading available audio devices...")
|
|
||||||
var propertySize: UInt32 = 0
|
var propertySize: UInt32 = 0
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
mSelector: kAudioHardwarePropertyDevices,
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
@ -159,7 +158,6 @@ class AudioDeviceManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
|
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
|
||||||
logger.info("Found \(deviceCount) total audio devices")
|
|
||||||
|
|
||||||
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
|
||||||
|
|
||||||
@ -186,11 +184,6 @@ class AudioDeviceManager: ObservableObject {
|
|||||||
return (id: deviceID, uid: uid, name: name)
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.availableDevices = devices.map { ($0.id, $0.uid, $0.name) }
|
self.availableDevices = devices.map { ($0.id, $0.uid, $0.name) }
|
||||||
@ -453,9 +446,17 @@ class AudioDeviceManager: ObservableObject {
|
|||||||
|
|
||||||
private func handleDeviceListChange() {
|
private func handleDeviceListChange() {
|
||||||
logger.info("Device list change detected")
|
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
|
loadAvailableDevices { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
if self.inputMode == .prioritized {
|
if self.inputMode == .prioritized {
|
||||||
self.selectHighestPriorityAvailableDevice()
|
self.selectHighestPriorityAvailableDevice()
|
||||||
} else if self.inputMode == .custom,
|
} else if self.inputMode == .custom,
|
||||||
@ -526,8 +527,6 @@ class AudioDeviceManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func notifyDeviceChange() {
|
private func notifyDeviceChange() {
|
||||||
if !isRecordingActive {
|
NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil)
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("AudioDeviceChanged"), object: nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user