PlaybackViewModel.swift 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import SwiftUI
  2. import Observation
  3. import AVFoundation
  4. // MARK: - PlaybackViewModel
  5. /// Manages audio playback state, timeline scrubbing, and event filtering
  6. /// for session detail/review screens using AVAudioPlayer for real audio output.
  7. @Observable
  8. final class PlaybackViewModel: NSObject, AVAudioPlayerDelegate {
  9. // MARK: - Playback State
  10. /// Current playback position in milliseconds.
  11. var currentPlaybackTimeMs: Double = 0
  12. /// Whether audio is currently playing.
  13. private(set) var isPlaying: Bool = false
  14. /// A user-facing reason why the selected recording cannot be played.
  15. private(set) var playbackError: String?
  16. /// Total session duration in milliseconds.
  17. var totalDurationMs: Double = 0
  18. // MARK: - Silence Skip State
  19. /// Whether auto-skip silence is enabled
  20. var isSilenceSkipEnabled: Bool = false
  21. /// Whether background silence analysis is active
  22. var isAnalyzingSilence: Bool = false
  23. /// Detected silent intervals
  24. var silentRanges: [SilenceRange] = []
  25. // MARK: - Audio Player Private State
  26. private var audioPlayer: AVAudioPlayer?
  27. private var activeAudioURL: URL?
  28. // MARK: - Computed Properties
  29. /// Formats the current playback position as `MM:SS`.
  30. var currentTimeFormatted: String {
  31. let totalSeconds = Int(currentPlaybackTimeMs / 1000)
  32. let minutes = totalSeconds / 60
  33. let seconds = totalSeconds % 60
  34. return String(format: "%02d:%02d", minutes, seconds)
  35. }
  36. /// Total duration formatted as `MM:SS`.
  37. var totalTimeFormatted: String {
  38. let totalSeconds = Int(totalDurationMs / 1000)
  39. let minutes = totalSeconds / 60
  40. let seconds = totalSeconds % 60
  41. return String(format: "%02d:%02d", minutes, seconds)
  42. }
  43. /// Playback progress as a normalized value (0.0 – 1.0).
  44. var progress: Double {
  45. guard totalDurationMs > 0 else { return 0 }
  46. return min(max(currentPlaybackTimeMs / totalDurationMs, 0), 1)
  47. }
  48. // MARK: - Private
  49. /// Tick timer that advances playback position at ~50ms intervals.
  50. private var playbackTimer: Timer?
  51. /// Tick interval in seconds (≈20 ticks/sec for smooth scrubber movement).
  52. private let tickInterval: TimeInterval = 0.05
  53. // MARK: - Init / Deinit
  54. override init() {
  55. super.init()
  56. }
  57. deinit {
  58. playbackTimer?.invalidate()
  59. audioPlayer?.stop()
  60. }
  61. // MARK: - Player Preparation
  62. /// Prepares the audio player for the given local recording file.
  63. func preparePlayer(path: String?, durationMs: Double = 0) {
  64. stopPlaybackTimer()
  65. audioPlayer?.stop()
  66. audioPlayer = nil
  67. activeAudioURL = nil
  68. playbackError = nil
  69. guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
  70. print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
  71. playbackError = "未找到本地录音文件,请先从云端同步或重新录制"
  72. if durationMs > 0 {
  73. self.totalDurationMs = durationMs
  74. }
  75. return
  76. }
  77. self.activeAudioURL = targetURL
  78. setupAudioSession()
  79. do {
  80. let player = try AVAudioPlayer(contentsOf: targetURL)
  81. player.delegate = self
  82. player.prepareToPlay()
  83. player.volume = 1.0
  84. self.audioPlayer = player
  85. if durationMs <= 0 {
  86. self.totalDurationMs = player.duration * 1000.0
  87. } else {
  88. self.totalDurationMs = durationMs
  89. }
  90. } catch {
  91. print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)")
  92. playbackError = "录音文件无法播放:\(error.localizedDescription)"
  93. }
  94. }
  95. private func setupAudioSession() {
  96. let session = AVAudioSession.sharedInstance()
  97. do {
  98. try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker])
  99. try session.setActive(true)
  100. } catch {
  101. print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)")
  102. }
  103. }
  104. // MARK: - Playback Controls
  105. /// Starts or resumes playback from the current position.
  106. func play() {
  107. guard !isPlaying else { return }
  108. guard let player = audioPlayer else {
  109. playbackError = "当前记录没有可播放的本地录音文件"
  110. return
  111. }
  112. setupAudioSession()
  113. player.currentTime = currentPlaybackTimeMs / 1000.0
  114. guard player.play() else {
  115. playbackError = "录音播放启动失败"
  116. return
  117. }
  118. isPlaying = true
  119. startPlaybackTimer()
  120. }
  121. /// Pauses playback at the current position.
  122. func pause() {
  123. guard isPlaying else { return }
  124. audioPlayer?.pause()
  125. isPlaying = false
  126. stopPlaybackTimer()
  127. }
  128. /// Toggles between playing and paused states.
  129. func togglePlayback() {
  130. if isPlaying {
  131. pause()
  132. } else {
  133. play()
  134. }
  135. }
  136. /// Seeks to a specific position in the timeline.
  137. /// - Parameter timeMs: The target position in milliseconds.
  138. func seekTo(timeMs: Double) {
  139. let target = min(max(timeMs, 0), totalDurationMs)
  140. currentPlaybackTimeMs = target
  141. if let player = audioPlayer {
  142. player.currentTime = target / 1000.0
  143. }
  144. }
  145. /// Convenience overload accepting Int64.
  146. func seekTo(timeMs: Int64) {
  147. seekTo(timeMs: Double(timeMs))
  148. }
  149. /// Seeks to a normalized progress value (0.0 – 1.0).
  150. /// - Parameter progress: Normalized position.
  151. func seekToProgress(_ progress: Double) {
  152. let clampedProgress = min(max(progress, 0), 1)
  153. seekTo(timeMs: clampedProgress * totalDurationMs)
  154. }
  155. /// Resets playback to the beginning.
  156. func reset() {
  157. pause()
  158. seekTo(timeMs: 0.0)
  159. }
  160. // MARK: - AVAudioPlayerDelegate
  161. func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
  162. isPlaying = false
  163. currentPlaybackTimeMs = totalDurationMs
  164. stopPlaybackTimer()
  165. }
  166. // MARK: - Private Timer & Silence Detection
  167. private func startPlaybackTimer() {
  168. playbackTimer?.invalidate()
  169. playbackTimer = Timer.scheduledTimer(
  170. withTimeInterval: tickInterval,
  171. repeats: true
  172. ) { [weak self] _ in
  173. guard let self else { return }
  174. self.tick()
  175. }
  176. if let timer = playbackTimer {
  177. RunLoop.main.add(timer, forMode: .common)
  178. }
  179. }
  180. private func stopPlaybackTimer() {
  181. playbackTimer?.invalidate()
  182. playbackTimer = nil
  183. }
  184. /// Runs silence detection asynchronously.
  185. func analyzeSilence(audioURL: URL?) {
  186. let targetPath = audioURL?.path ?? activeAudioURL?.path
  187. guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
  188. silentRanges = []
  189. isAnalyzingSilence = false
  190. return
  191. }
  192. isAnalyzingSilence = true
  193. Task {
  194. let ranges = await SilenceDetector.detectSilence(in: url)
  195. await MainActor.run {
  196. self.silentRanges = ranges
  197. self.isAnalyzingSilence = false
  198. }
  199. }
  200. }
  201. private func checkForSilenceAndSkip() {
  202. guard isSilenceSkipEnabled && !silentRanges.isEmpty else { return }
  203. let currentTimeSec = currentPlaybackTimeMs / 1000.0
  204. for range in silentRanges {
  205. if currentTimeSec >= range.start && currentTimeSec < range.end {
  206. let targetMs = range.end * 1000.0
  207. seekTo(timeMs: targetMs)
  208. print("[PlaybackViewModel] Auto-skipped silence from \(range.start)s to \(range.end)s")
  209. break
  210. }
  211. }
  212. }
  213. /// Advances playback by one tick interval, syncing with audioPlayer if active.
  214. private func tick() {
  215. guard let player = audioPlayer, player.isPlaying else {
  216. isPlaying = false
  217. stopPlaybackTimer()
  218. return
  219. }
  220. currentPlaybackTimeMs = player.currentTime * 1000.0
  221. checkForSilenceAndSkip()
  222. if currentPlaybackTimeMs >= totalDurationMs {
  223. currentPlaybackTimeMs = totalDurationMs
  224. pause()
  225. }
  226. }
  227. // MARK: - Event Queries
  228. func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] {
  229. events.sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  230. }
  231. func sortedEvents(from session: CelestiaSession) -> [CelestiaTimelineEvent] {
  232. sortedEvents(from: session.events)
  233. }
  234. func filteredEvents(
  235. from events: [CelestiaTimelineEvent],
  236. type: String?
  237. ) -> [CelestiaTimelineEvent] {
  238. let filtered: [CelestiaTimelineEvent]
  239. if let type {
  240. filtered = events.filter { $0.eventType == type }
  241. } else {
  242. filtered = events
  243. }
  244. return filtered.sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  245. }
  246. func nearestEvent(
  247. from events: [CelestiaTimelineEvent],
  248. toleranceMs: Double = 500
  249. ) -> CelestiaTimelineEvent? {
  250. events
  251. .min(by: {
  252. abs(Double($0.relativeTimeMs) - currentPlaybackTimeMs)
  253. < abs(Double($1.relativeTimeMs) - currentPlaybackTimeMs)
  254. })
  255. .flatMap { event in
  256. abs(Double(event.relativeTimeMs) - currentPlaybackTimeMs) <= toleranceMs
  257. ? event
  258. : nil
  259. }
  260. }
  261. }