Procházet zdrojové kódy

feat(audio): 增加音频中断强刷盘保护 (AudioRecordingWriteProtector) 与系统电话/闹钟打断恢复状态机

bob.yuxinyang před 1 měsícem
rodič
revize
0be0cb174c

+ 166 - 28
CelestiaTrace/Services/Audio/RealAudioRecorder.swift

@@ -2,6 +2,53 @@ import Foundation
 import Combine
 import AVFoundation
 
+/// Tracks whether a recording was actively running when a system interruption
+/// began. Keeping this separate from `isPaused` prevents a user-paused
+/// recording from being resumed automatically after a phone call or alarm.
+struct AudioInterruptionRecoveryState {
+    private(set) var isInterrupted = false
+    private var shouldResumeRecording = false
+
+    mutating func interruptionBegan(isRecording: Bool, isPaused: Bool) -> Bool {
+        guard !isInterrupted else { return false }
+        isInterrupted = true
+        shouldResumeRecording = isRecording && !isPaused
+        return shouldResumeRecording
+    }
+
+    mutating func interruptionEnded(systemRecommendsResume: Bool) -> Bool {
+        guard isInterrupted else { return false }
+        isInterrupted = false
+        defer { shouldResumeRecording = false }
+        return shouldResumeRecording && systemRecommendsResume
+    }
+
+    mutating func cancelAutomaticResume() {
+        shouldResumeRecording = false
+    }
+
+    mutating func reset() {
+        isInterrupted = false
+        shouldResumeRecording = false
+    }
+}
+
+/// Forces the bytes already emitted by `AVAudioRecorder` through the file
+/// system cache. `pause()`/`stop()` remains responsible for flushing the AAC
+/// encoder; this is an additional persistence fallback for interruptions.
+enum AudioRecordingWriteProtector {
+    static func synchronizeFile(at url: URL) throws {
+        let handle = try FileHandle(forUpdating: url)
+        do {
+            try handle.synchronize()
+            try handle.close()
+        } catch {
+            try? handle.close()
+            throw error
+        }
+    }
+}
+
 /// Real audio recorder using AVAudioRecorder for production recording.
 /// Requests microphone permissions, configures AVAudioSession, and outputs AAC (.m4a) files.
 final class RealAudioRecorder: AudioRecorderProtocol {
@@ -13,6 +60,8 @@ final class RealAudioRecorder: AudioRecorderProtocol {
     @Published var waveformSamples: [Float] = []
     @Published var outputFileURL: URL? = nil
     @Published var isPaused: Bool = false
+    @Published var statusMessage: String = "正在准备"
+    @Published var errorMessage: String?
     
     // MARK: - Private Properties
     
@@ -20,6 +69,7 @@ final class RealAudioRecorder: AudioRecorderProtocol {
     private var timer: Timer?
     private let timerInterval: TimeInterval = 0.05
     private let maxSampleCount = 200
+    private var interruptionState = AudioInterruptionRecoveryState()
     
     init() {
         NotificationCenter.default.addObserver(
@@ -40,6 +90,9 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         currentAmplitude = 0
         waveformSamples = []
         outputFileURL = nil
+        statusMessage = "正在准备"
+        errorMessage = nil
+        interruptionState.reset()
         
         // Request Permission
         AVAudioApplication.requestRecordPermission { [weak self] granted in
@@ -49,6 +102,10 @@ final class RealAudioRecorder: AudioRecorderProtocol {
                     self.setupAndRecord()
                 }
             } else {
+                DispatchQueue.main.async {
+                    self.statusMessage = "无法开始录音"
+                    self.errorMessage = "未获得麦克风权限,请在系统设置中允许访问。"
+                }
                 print("[RealAudioRecorder] Microphone permission denied")
             }
         }
@@ -82,56 +139,124 @@ final class RealAudioRecorder: AudioRecorderProtocol {
                 self.audioRecorder = recorder
                 self.isRecording = true
                 self.isPaused = false
+                self.statusMessage = "正在记录"
                 self.startTimer()
                 print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)")
             } else {
                 try? session.setActive(false, options: .notifyOthersOnDeactivation)
+                statusMessage = "无法开始录音"
+                errorMessage = "录音器准备失败,请检查当前音频设备后重试。"
                 print("[RealAudioRecorder] Failed to prepare or start recording")
             }
         } catch {
+            statusMessage = "无法开始录音"
+            errorMessage = "录音启动失败:\(error.localizedDescription)"
             print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)")
         }
     }
     
     func stopRecording() {
-        guard isRecording else { return }
-        
-        invalidateTimer()
-        audioRecorder?.stop()
-        audioRecorder = nil
-        isRecording = false
-        isPaused = false
-        currentAmplitude = 0
-        
-        // Deactivate audio session
-        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
-        print("[RealAudioRecorder] Recording stopped. File saved.")
+        if case .failure(let error) = stopRecordingAndProtect() {
+            errorMessage = "录音已停止,但写盘保护失败:\(error.localizedDescription)"
+        }
+    }
+
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        completion(stopRecordingAndProtect())
     }
     
     func pauseRecording() {
         guard isRecording else { return }
+        interruptionState.cancelAutomaticResume()
         audioRecorder?.pause()
+        captureLatestRecorderTime()
         isPaused = true
         currentAmplitude = 0
+        statusMessage = "已暂停"
         invalidateTimer()
     }
     
     func resumeRecording() {
         guard isRecording else { return }
+        guard !interruptionState.isInterrupted else {
+            statusMessage = "系统音频仍在占用,等待中断结束"
+            return
+        }
+        interruptionState.cancelAutomaticResume()
+        _ = resumePausedRecording()
+    }
+
+    // MARK: - Private Helpers
+
+    private func stopRecordingAndProtect() -> Result<URL?, Error> {
+        if audioRecorder != nil {
+            captureLatestRecorderTime()
+            invalidateTimer()
+            audioRecorder?.stop()
+            audioRecorder = nil
+            isRecording = false
+            isPaused = false
+            currentAmplitude = 0
+            statusMessage = "已保存"
+            interruptionState.reset()
+
+            try? AVAudioSession.sharedInstance().setActive(
+                false,
+                options: .notifyOthersOnDeactivation
+            )
+            print("[RealAudioRecorder] Recording stopped. File saved.")
+        }
+
+        guard let outputFileURL else { return .success(nil) }
+
+        do {
+            try AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL)
+            return .success(outputFileURL)
+        } catch {
+            return .failure(error)
+        }
+    }
+
+    @discardableResult
+    private func resumePausedRecording() -> Bool {
         do {
             try AVAudioSession.sharedInstance().setActive(true)
             guard audioRecorder?.record() == true else {
+                statusMessage = "恢复录音失败"
+                errorMessage = "系统音频已恢复,但录音器未能继续,请手动重试。"
                 print("[RealAudioRecorder] Failed to resume recording")
-                return
+                return false
             }
             isPaused = false
+            statusMessage = "正在记录"
+            errorMessage = nil
             startTimer()
+            return true
         } catch {
+            statusMessage = "恢复录音失败"
+            errorMessage = "重新激活音频会话失败:\(error.localizedDescription)"
             print("[RealAudioRecorder] Failed to reactivate audio session: \(error.localizedDescription)")
+            return false
         }
     }
     
-    // MARK: - Private Helpers
+    private func captureLatestRecorderTime() {
+        guard let recorder = audioRecorder else { return }
+        elapsedTime = max(elapsedTime, recorder.currentTime)
+    }
+
+    private func protectInterruptedRecordingTail() {
+        guard let outputFileURL else { return }
+        do {
+            try AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL)
+            statusMessage = "系统中断,已保护已录内容"
+            print("[RealAudioRecorder] Interrupted recording bytes synchronized.")
+        } catch {
+            statusMessage = "系统中断,录音已暂停"
+            errorMessage = "录音尾段写盘保护失败:\(error.localizedDescription)"
+            print("[RealAudioRecorder] Failed to synchronize interrupted recording: \(error.localizedDescription)")
+        }
+    }
     
     private func startTimer() {
         invalidateTimer()
@@ -178,24 +303,34 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         
         switch type {
         case .began:
-            DispatchQueue.main.async { [weak self] in
-                guard let self else { return }
-                if self.isRecording && !self.isPaused {
-                    self.pauseRecording()
-                    print("[RealAudioRecorder] Audio session interrupted. Recording automatically paused.")
-                }
+            guard interruptionState.interruptionBegan(
+                isRecording: isRecording,
+                isPaused: isPaused
+            ) else {
+                return
             }
+
+            captureLatestRecorderTime()
+            audioRecorder?.pause()
+            isPaused = true
+            currentAmplitude = 0
+            invalidateTimer()
+            statusMessage = "系统中断,正在保护已录内容"
+            protectInterruptedRecordingTail()
+            print("[RealAudioRecorder] Audio session interrupted. Recording protected and automatically paused.")
         case .ended:
-            guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
+            let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
             let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
-            if options.contains(.shouldResume) {
-                DispatchQueue.main.async { [weak self] in
-                    guard let self else { return }
-                    if self.isRecording && self.isPaused {
-                        self.resumeRecording()
-                        print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
-                    }
+            let shouldResume = interruptionState.interruptionEnded(
+                systemRecommendsResume: options.contains(.shouldResume)
+            )
+
+            if shouldResume, isRecording, isPaused {
+                if resumePausedRecording() {
+                    print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
                 }
+            } else if isRecording, isPaused {
+                statusMessage = "系统中断已结束,请手动继续录音"
             }
         @unknown default:
             break
@@ -206,6 +341,9 @@ final class RealAudioRecorder: AudioRecorderProtocol {
         NotificationCenter.default.removeObserver(self)
         invalidateTimer()
         audioRecorder?.stop()
+        if let outputFileURL {
+            try? AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL)
+        }
     }
 }
 

+ 1 - 1
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -256,7 +256,7 @@ struct ActiveRecordingView: View {
                 }
                 .frame(width: 10, height: 10)
 
-                Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage)
+                Text(recordingVM.statusMessage)
                     .font(.system(size: 11, weight: .semibold, design: .monospaced))
                     .foregroundStyle(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
                     .tracking(1.2)

+ 46 - 0
Tests/CelestiaTraceTests/BLEFilteringTests.swift

@@ -61,3 +61,49 @@ final class SegmentedAudioAnalysisTests: XCTestCase {
         )
     }
 }
+
+final class AudioInterruptionRecoveryTests: XCTestCase {
+    func testActiveRecordingAutomaticallyResumesWhenSystemRecommendsIt() {
+        var state = AudioInterruptionRecoveryState()
+
+        XCTAssertTrue(state.interruptionBegan(isRecording: true, isPaused: false))
+        XCTAssertTrue(state.isInterrupted)
+        XCTAssertTrue(state.interruptionEnded(systemRecommendsResume: true))
+        XCTAssertFalse(state.isInterrupted)
+    }
+
+    func testUserPausedRecordingDoesNotAutomaticallyResume() {
+        var state = AudioInterruptionRecoveryState()
+
+        XCTAssertFalse(state.interruptionBegan(isRecording: true, isPaused: true))
+        XCTAssertFalse(state.interruptionEnded(systemRecommendsResume: true))
+    }
+
+    func testManualPauseDuringInterruptionCancelsAutomaticResume() {
+        var state = AudioInterruptionRecoveryState()
+
+        XCTAssertTrue(state.interruptionBegan(isRecording: true, isPaused: false))
+        state.cancelAutomaticResume()
+
+        XCTAssertFalse(state.interruptionEnded(systemRecommendsResume: true))
+    }
+
+    func testMissingSystemResumeRecommendationLeavesRecordingPaused() {
+        var state = AudioInterruptionRecoveryState()
+
+        XCTAssertTrue(state.interruptionBegan(isRecording: true, isPaused: false))
+        XCTAssertFalse(state.interruptionEnded(systemRecommendsResume: false))
+    }
+
+    func testWriteProtectorPreservesFileContents() throws {
+        let url = FileManager.default.temporaryDirectory
+            .appendingPathComponent("audio-write-protection-\(UUID().uuidString).m4a")
+        let expected = Data("recorded-tail".utf8)
+        try expected.write(to: url)
+        defer { try? FileManager.default.removeItem(at: url) }
+
+        try AudioRecordingWriteProtector.synchronizeFile(at: url)
+
+        XCTAssertEqual(try Data(contentsOf: url), expected)
+    }
+}