RecordingViewModel.swift 5.2 KB

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