RecordingViewModel.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import SwiftUI
  2. import SwiftData
  3. import Observation
  4. // MARK: - RecordingViewModel
  5. /// Manages recording state and bridges the ObservableObject-based AudioRecorder
  6. /// into the iOS 17 Observation framework via Timer-based polling.
  7. @Observable
  8. final class RecordingViewModel {
  9. // MARK: - Published State
  10. private(set) var isRecording: Bool = false
  11. private(set) var initialDuration: TimeInterval = 0
  12. private(set) var elapsedTime: TimeInterval = 0
  13. private(set) var currentAmplitude: Float = 0
  14. private(set) var waveformSamples: [Float] = []
  15. private(set) var isPaused: Bool = false
  16. private(set) var sourceDisplayName: String
  17. private(set) var statusMessage: String = "正在准备"
  18. private(set) var errorMessage: String?
  19. /// URL of the segment currently being recorded. Exposed as observed state
  20. /// so the recovery coordinator can persist it as soon as it is allocated.
  21. private(set) var outputFileURL: URL?
  22. // MARK: - Computed Properties
  23. /// Formats elapsed time as `HH:MM:SS`.
  24. var elapsedTimeFormatted: String {
  25. let totalSeconds = Int(elapsedTime)
  26. let hours = totalSeconds / 3600
  27. let minutes = (totalSeconds % 3600) / 60
  28. let seconds = totalSeconds % 60
  29. return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
  30. }
  31. // MARK: - Private
  32. /// Concrete recorder instance conforming to AudioRecorderProtocol.
  33. private let recorder: any AudioRecorderProtocol
  34. /// Timer that syncs recorder state → ViewModel state at ~60 Hz.
  35. private var syncTimer: Timer?
  36. // MARK: - Init
  37. init() {
  38. self.recorder = RealAudioRecorder()
  39. self.sourceDisplayName = "iPhone 本机"
  40. }
  41. init(source: RecordingSourceChoice) {
  42. switch source {
  43. case .iPhone:
  44. self.recorder = RealAudioRecorder()
  45. case .spark(let deviceID, let displayName):
  46. self.recorder = SparkAudioRecorder(deviceID: deviceID, displayName: displayName)
  47. }
  48. self.sourceDisplayName = source.displayName
  49. }
  50. init(recorder: any AudioRecorderProtocol) {
  51. self.recorder = recorder
  52. self.sourceDisplayName = recorder.sourceDisplayName
  53. }
  54. deinit {
  55. syncTimer?.invalidate()
  56. }
  57. // MARK: - Recording Controls
  58. /// Starts recording and begins polling the recorder for state updates.
  59. /// - Parameter initialDuration: Prior recorded duration in seconds when continuing a session.
  60. func startRecording(initialDuration: TimeInterval = 0) {
  61. self.initialDuration = initialDuration
  62. self.elapsedTime = initialDuration
  63. recorder.startRecording()
  64. syncState()
  65. startSyncTimer()
  66. }
  67. /// Stops recording and cleans up the sync timer.
  68. func stopRecording() {
  69. recorder.stopRecording()
  70. isRecording = false
  71. stopSyncTimer()
  72. syncState() // Final sync
  73. }
  74. /// Stops the active source and reports whether the source confirmed that it
  75. /// is safe to finalize the session. Spark uses this to await PQ_DEV&STO.
  76. func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
  77. recorder.stopRecording { [weak self] result in
  78. guard let self else { return }
  79. self.isRecording = false
  80. self.stopSyncTimer()
  81. self.syncState()
  82. completion(result)
  83. }
  84. }
  85. /// Pauses the current recording session.
  86. func pauseRecording() {
  87. recorder.pauseRecording()
  88. syncState()
  89. }
  90. /// Resumes a paused recording session.
  91. func resumeRecording() {
  92. recorder.resumeRecording()
  93. syncState()
  94. }
  95. // MARK: - Timeline Event Creation
  96. /// Creates a PHOTO timeline event attached to the given session.
  97. /// - Parameter session: The active `CelestiaSession` to attach the event to.
  98. /// - Returns: The newly created `CelestiaTimelineEvent`.
  99. @discardableResult
  100. func addPhotoEvent(
  101. to session: CelestiaSession,
  102. relativeTimeMs: Int64? = nil,
  103. localFilePath: String? = nil,
  104. note: String? = nil
  105. ) -> CelestiaTimelineEvent {
  106. let relativeMs = relativeTimeMs ?? Int64(elapsedTime * 1000)
  107. let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "PHOTO")
  108. event.localFilePath = localFilePath
  109. let trimmedNote = note?.trimmingCharacters(in: .whitespacesAndNewlines)
  110. event.textContent = trimmedNote?.isEmpty == false ? trimmedNote : nil
  111. session.events.append(event)
  112. session.isSynced = false
  113. session.syncState = .pending
  114. return event
  115. }
  116. /// Creates a NOTE timeline event attached to the given session.
  117. /// - Parameters:
  118. /// - session: The active `CelestiaSession` to attach the event to.
  119. /// - text: The note text content.
  120. /// - Returns: The newly created `CelestiaTimelineEvent`.
  121. @discardableResult
  122. func addNoteEvent(to session: CelestiaSession, text: String) -> CelestiaTimelineEvent {
  123. let relativeMs = Int64(elapsedTime * 1000)
  124. let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "NOTE")
  125. event.textContent = text
  126. session.events.append(event)
  127. session.isSynced = false
  128. session.syncState = .pending
  129. return event
  130. }
  131. /// Creates a MARKER timeline event attached to the given session.
  132. /// - Parameters:
  133. /// - session: The active `CelestiaSession` to attach the event to.
  134. /// - text: The marker label text.
  135. /// - Returns: The newly created `CelestiaTimelineEvent`.
  136. @discardableResult
  137. func addMarkerEvent(to session: CelestiaSession, text: String) -> CelestiaTimelineEvent {
  138. let relativeMs = Int64(elapsedTime * 1000)
  139. let event = CelestiaTimelineEvent(relativeTimeMs: relativeMs, eventType: "MARKER")
  140. event.textContent = text
  141. session.events.append(event)
  142. session.isSynced = false
  143. session.syncState = .pending
  144. return event
  145. }
  146. // MARK: - Private Sync Timer
  147. private func startSyncTimer() {
  148. syncTimer?.invalidate()
  149. // ~60 Hz polling to bridge ObservableObject → @Observable
  150. syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
  151. self?.syncState()
  152. }
  153. // Ensure timer fires during UI tracking (scrolling, etc.)
  154. if let timer = syncTimer {
  155. RunLoop.main.add(timer, forMode: .common)
  156. }
  157. }
  158. private func stopSyncTimer() {
  159. syncTimer?.invalidate()
  160. syncTimer = nil
  161. }
  162. /// Pulls current values from the ObservableObject recorder into @Observable properties.
  163. private func syncState() {
  164. isRecording = recorder.isRecording
  165. elapsedTime = initialDuration + recorder.elapsedTime
  166. currentAmplitude = recorder.currentAmplitude
  167. waveformSamples = recorder.waveformSamples
  168. isPaused = recorder.isPaused
  169. sourceDisplayName = recorder.sourceDisplayName
  170. statusMessage = recorder.statusMessage
  171. errorMessage = recorder.errorMessage
  172. outputFileURL = recorder.outputFileURL
  173. }
  174. }