RecordingViewModel.swift 6.6 KB

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