RecordingViewModel.swift 4.9 KB

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