|
|
@@ -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)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|