Fix crash when MuteDeck stops a recording #5

Merged
superdooper-coder merged 1 commits from codex/fix-recording-stop-crash into main 2026-07-15 11:26:35 +02:00
+42 -5
View File
@@ -21,9 +21,12 @@ final class AudioManager: NSObject, ObservableObject {
private let audioProcessController = AudioProcessController() private let audioProcessController = AudioProcessController()
private let permission = AudioRecordingPermission() private let permission = AudioRecordingPermission()
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
private let audioFileLock = NSLock()
private var isTapActive = false private var isTapActive = false
private var isRestartingSystemTap = false private var isRestartingSystemTap = false
private var isAcceptingAudio = false
private var micRetryCount = 0 private var micRetryCount = 0
private var pendingMicRestart: DispatchWorkItem?
private let maxMicRetries = 3 private let maxMicRetries = 3
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -122,20 +125,26 @@ final class AudioManager: NSObject, ObservableObject {
let id = sessionID.uuidString let id = sessionID.uuidString
let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a") let micURL = base.appendingPathComponent("meetingnotes-\(id)-mic.m4a")
let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a") let systemURL = base.appendingPathComponent("meetingnotes-\(id)-system.m4a")
micAudioFile = try AVAudioFile( let newMicAudioFile = try AVAudioFile(
forWriting: micURL, forWriting: micURL,
settings: settings, settings: settings,
commonFormat: .pcmFormatFloat32, commonFormat: .pcmFormatFloat32,
interleaved: false interleaved: false
) )
systemAudioFile = try AVAudioFile( let newSystemAudioFile = try AVAudioFile(
forWriting: systemURL, forWriting: systemURL,
settings: settings, settings: settings,
commonFormat: .pcmFormatFloat32, commonFormat: .pcmFormatFloat32,
interleaved: false interleaved: false
) )
audioFileLock.lock()
micAudioFile = newMicAudioFile
systemAudioFile = newSystemAudioFile
micAudioURL = micURL micAudioURL = micURL
systemAudioURL = systemURL systemAudioURL = systemURL
isAcceptingAudio = true
audioFileLock.unlock()
} }
private func startMicrophoneTap() { private func startMicrophoneTap() {
@@ -161,12 +170,17 @@ final class AudioManager: NSObject, ObservableObject {
} }
private func restartMicrophone() { private func restartMicrophone() {
guard (isRecording || micAudioFile != nil), micRetryCount < maxMicRetries else { return } guard hasActiveAudioFiles(), micRetryCount < maxMicRetries else { return }
micRetryCount += 1 micRetryCount += 1
pendingMicRestart?.cancel()
cleanupAudioEngine() cleanupAudioEngine()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.startMicrophoneTap() let restart = DispatchWorkItem { [weak self] in
guard let self, self.hasActiveAudioFiles() else { return }
self.startMicrophoneTap()
} }
pendingMicRestart = restart
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: restart)
} }
private func cleanupAudioEngine() { private func cleanupAudioEngine() {
@@ -294,6 +308,12 @@ final class AudioManager: NSObject, ObservableObject {
return inputBuffer return inputBuffer
} }
guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return } guard status != .error, conversionError == nil, outputBuffer.frameLength > 0 else { return }
audioFileLock.lock()
defer { audioFileLock.unlock() }
guard isAcceptingAudio else {
return
}
do { do {
switch source { switch source {
case .mic: case .mic:
@@ -327,7 +347,16 @@ final class AudioManager: NSObject, ObservableObject {
private func stopCaptureAndCloseFiles() -> [URL?] { private func stopCaptureAndCloseFiles() -> [URL?] {
isRecording = false isRecording = false
pendingMicRestart?.cancel()
pendingMicRestart = nil
AudioLevelManager.shared.updateRecordingState(false) AudioLevelManager.shared.updateRecordingState(false)
// Stop new writes and wait for any callback already writing before
// AVAudioFile is finalized and released.
audioFileLock.lock()
isAcceptingAudio = false
audioFileLock.unlock()
if isTapActive { if isTapActive {
processTap?.invalidate() processTap?.invalidate()
processTap = nil processTap = nil
@@ -337,11 +366,13 @@ final class AudioManager: NSObject, ObservableObject {
micRetryCount = 0 micRetryCount = 0
resetAudioLevels() resetAudioLevels()
audioFileLock.lock()
let micHasAudio = (micAudioFile?.length ?? 0) > 0 let micHasAudio = (micAudioFile?.length ?? 0) > 0
let systemHasAudio = (systemAudioFile?.length ?? 0) > 0 let systemHasAudio = (systemAudioFile?.length ?? 0) > 0
micAudioFile = nil micAudioFile = nil
systemAudioFile = nil systemAudioFile = nil
let files: [URL?] = [micHasAudio ? micAudioURL : nil, systemHasAudio ? systemAudioURL : nil] let files: [URL?] = [micHasAudio ? micAudioURL : nil, systemHasAudio ? systemAudioURL : nil]
audioFileLock.unlock()
if !micHasAudio, let micAudioURL { try? FileManager.default.removeItem(at: micAudioURL) } if !micHasAudio, let micAudioURL { try? FileManager.default.removeItem(at: micAudioURL) }
if !systemHasAudio, let systemAudioURL { try? FileManager.default.removeItem(at: systemAudioURL) } if !systemHasAudio, let systemAudioURL { try? FileManager.default.removeItem(at: systemAudioURL) }
micAudioURL = nil micAudioURL = nil
@@ -365,6 +396,12 @@ final class AudioManager: NSObject, ObservableObject {
AudioLevelManager.shared.updateMicLevel(0) AudioLevelManager.shared.updateMicLevel(0)
AudioLevelManager.shared.updateSystemLevel(0) AudioLevelManager.shared.updateSystemLevel(0)
} }
private func hasActiveAudioFiles() -> Bool {
audioFileLock.lock()
defer { audioFileLock.unlock() }
return isAcceptingAudio
}
private func handleAudioEngineConfigurationChange() { private func handleAudioEngineConfigurationChange() {
restartMicrophone() restartMicrophone()