PlaybackViewModel.swift 10 KB

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