PlaybackViewModel.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import SwiftUI
  2. import Observation
  3. import AVFoundation
  4. private final class WeakPlaybackViewModelReference: @unchecked Sendable {
  5. weak var value: PlaybackViewModel?
  6. init(_ value: PlaybackViewModel) {
  7. self.value = value
  8. }
  9. }
  10. // MARK: - PlaybackViewModel
  11. /// Manages audio playback state, timeline scrubbing, and event filtering
  12. /// for session detail/review screens using asynchronously prepared AVPlayer media.
  13. @Observable
  14. final class PlaybackViewModel: NSObject {
  15. // MARK: - Playback State
  16. /// Current playback position in milliseconds.
  17. var currentPlaybackTimeMs: Double = 0
  18. /// Whether audio is currently playing.
  19. private(set) var isPlaying: Bool = false
  20. /// A user-facing reason why the selected recording cannot be played.
  21. private(set) var playbackError: String?
  22. /// Whether the local media asset is still being prepared.
  23. private(set) var isPreparingPlayback: Bool = false
  24. /// Whether the player is ready to accept playback and seek commands.
  25. private(set) var isPlaybackReady: Bool = false
  26. /// Whether a slider or timeline playhead drag is active.
  27. private(set) var isScrubbing: Bool = false
  28. /// Total session duration in milliseconds.
  29. var totalDurationMs: Double = 0
  30. // MARK: - Silence Skip State
  31. /// Whether auto-skip silence is enabled
  32. var isSilenceSkipEnabled: Bool = true
  33. /// Whether background silence analysis is active
  34. var isAnalyzingSilence: Bool = false
  35. /// Detected silent intervals
  36. var silentRanges: [SilenceRange] = []
  37. /// Amplitude levels extracted from the playback file on the live-recording scale.
  38. var waveformSamples: [Float] = []
  39. /// Cheap invalidation token for the otherwise static waveform drawing layer.
  40. private(set) var waveformRevision: Int = 0
  41. /// Whether playback waveform and silence analysis is active.
  42. var isAnalyzingAudio: Bool = false
  43. // MARK: - Audio Player Private State
  44. private var player: AVPlayer?
  45. private var playerItem: AVPlayerItem?
  46. private var activeAudioURL: URL?
  47. private var playerPreparationTask: Task<Void, Never>?
  48. private var audioAnalysisTask: Task<Void, Never>?
  49. private var audioAnalysisSession: AudioAnalysisSession?
  50. private var playbackTimeObserver: Any?
  51. private var playbackEndObserver: NSObjectProtocol?
  52. private var shouldPlayWhenReady = false
  53. private var shouldResumeAfterScrubbing = false
  54. private var isSilenceSkipSeekInFlight = false
  55. // MARK: - Computed Properties
  56. /// Formats the current playback position as `MM:SS`.
  57. var currentTimeFormatted: String {
  58. let totalSeconds = Int(currentPlaybackTimeMs / 1000)
  59. let minutes = totalSeconds / 60
  60. let seconds = totalSeconds % 60
  61. return String(format: "%02d:%02d", minutes, seconds)
  62. }
  63. /// Total duration formatted as `MM:SS`.
  64. var totalTimeFormatted: String {
  65. let totalSeconds = Int(totalDurationMs / 1000)
  66. let minutes = totalSeconds / 60
  67. let seconds = totalSeconds % 60
  68. return String(format: "%02d:%02d", minutes, seconds)
  69. }
  70. /// Playback progress as a normalized value (0.0 – 1.0).
  71. var progress: Double {
  72. guard totalDurationMs > 0 else { return 0 }
  73. return min(max(currentPlaybackTimeMs / totalDurationMs, 0), 1)
  74. }
  75. // MARK: - Private
  76. // MARK: - Init / Deinit
  77. override init() {
  78. super.init()
  79. }
  80. deinit {
  81. playerPreparationTask?.cancel()
  82. audioAnalysisTask?.cancel()
  83. audioAnalysisSession?.cancel()
  84. removePlayerObservers()
  85. player?.pause()
  86. }
  87. // MARK: - Player Preparation
  88. /// Prepares the audio player for the given local recording file.
  89. func preparePlayer(path: String?, durationMs: Double = 0) {
  90. playerPreparationTask?.cancel()
  91. removePlayerObservers()
  92. player?.pause()
  93. player = nil
  94. playerItem = nil
  95. activeAudioURL = nil
  96. playbackError = nil
  97. isPlaying = false
  98. isPlaybackReady = false
  99. isPreparingPlayback = false
  100. shouldPlayWhenReady = false
  101. currentPlaybackTimeMs = 0
  102. if durationMs > 0 {
  103. totalDurationMs = durationMs
  104. }
  105. guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
  106. print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
  107. playbackError = "未找到本地录音文件,请先从云端同步或重新录制"
  108. return
  109. }
  110. self.activeAudioURL = targetURL
  111. isPreparingPlayback = true
  112. // AVURLAsset creation is lightweight. Properties that may touch a long
  113. // AAC file are loaded asynchronously so navigation never waits for them.
  114. playerPreparationTask = Task { @MainActor [weak self] in
  115. let asset = AVURLAsset(url: targetURL)
  116. do {
  117. let duration = try await asset.load(.duration)
  118. let isPlayable = try await asset.load(.isPlayable)
  119. try Task.checkCancellation()
  120. guard let self else { return }
  121. guard self.activeAudioURL == targetURL else { return }
  122. guard isPlayable else {
  123. self.isPreparingPlayback = false
  124. self.playbackError = "录音文件无法播放"
  125. return
  126. }
  127. let item = AVPlayerItem(asset: asset)
  128. let player = AVPlayer(playerItem: item)
  129. player.automaticallyWaitsToMinimizeStalling = true
  130. self.playerItem = item
  131. self.player = player
  132. self.installPlayerObservers(player: player, item: item)
  133. let seconds = CMTimeGetSeconds(duration)
  134. if seconds.isFinite && seconds > 0 {
  135. self.totalDurationMs = seconds * 1_000
  136. }
  137. self.isPreparingPlayback = false
  138. self.isPlaybackReady = true
  139. self.performSeek(to: self.currentPlaybackTimeMs) { [weak self] _ in
  140. guard let self, self.shouldPlayWhenReady else { return }
  141. self.startPreparedPlayer()
  142. }
  143. } catch is CancellationError {
  144. return
  145. } catch {
  146. guard let self, self.activeAudioURL == targetURL else { return }
  147. print("[PlaybackViewModel] Failed to prepare AVPlayer: \(error.localizedDescription)")
  148. self.isPreparingPlayback = false
  149. self.playbackError = "录音文件无法播放:\(error.localizedDescription)"
  150. }
  151. }
  152. }
  153. private func setupAudioSession() {
  154. let session = AVAudioSession.sharedInstance()
  155. do {
  156. // `.playback` already routes through the speaker by default.
  157. // `.defaultToSpeaker` is valid only with `.playAndRecord`.
  158. try session.setCategory(.playback, mode: .default)
  159. try session.setActive(true)
  160. } catch {
  161. print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)")
  162. }
  163. }
  164. // MARK: - Playback Controls
  165. /// Starts or resumes playback from the current position.
  166. func play() {
  167. guard !isPlaying else { return }
  168. shouldPlayWhenReady = true
  169. guard isPlaybackReady, player != nil else {
  170. if isPreparingPlayback {
  171. return
  172. }
  173. playbackError = "当前记录没有可播放的本地录音文件"
  174. return
  175. }
  176. setupAudioSession()
  177. startPreparedPlayer()
  178. }
  179. /// Pauses playback at the current position.
  180. func pause() {
  181. shouldPlayWhenReady = false
  182. player?.pause()
  183. isPlaying = false
  184. }
  185. /// Toggles between playing and paused states.
  186. func togglePlayback() {
  187. if isPlaying {
  188. pause()
  189. } else {
  190. play()
  191. }
  192. }
  193. /// Seeks to a specific position in the timeline.
  194. /// - Parameter timeMs: The target position in milliseconds.
  195. func seekTo(timeMs: Double) {
  196. let target = min(max(timeMs, 0), totalDurationMs)
  197. currentPlaybackTimeMs = target
  198. audioAnalysisSession?.prioritize(time: target / 1_000)
  199. performSeek(to: target)
  200. }
  201. /// Begins an interactive seek. Playback pauses once and no decoder seeks
  202. /// occur until the gesture ends.
  203. func beginScrubbing() {
  204. guard !isScrubbing else { return }
  205. isScrubbing = true
  206. shouldResumeAfterScrubbing = isPlaying || shouldPlayWhenReady
  207. pause()
  208. }
  209. /// Updates only the visual position while an interactive seek is active.
  210. func previewScrub(to timeMs: Double) {
  211. let target = min(max(timeMs, 0), totalDurationMs)
  212. currentPlaybackTimeMs = target
  213. audioAnalysisSession?.prioritize(time: target / 1_000)
  214. }
  215. /// Commits one accurate player seek, then restores playback only when it
  216. /// was active before the drag began.
  217. func endScrubbing(at timeMs: Double) {
  218. let target = min(max(timeMs, 0), totalDurationMs)
  219. currentPlaybackTimeMs = target
  220. isScrubbing = false
  221. let shouldResume = shouldResumeAfterScrubbing
  222. shouldResumeAfterScrubbing = false
  223. performSeek(to: target) { [weak self] finished in
  224. guard let self, shouldResume else { return }
  225. if finished || self.isPreparingPlayback {
  226. self.play()
  227. }
  228. }
  229. }
  230. /// Convenience overload accepting Int64.
  231. func seekTo(timeMs: Int64) {
  232. seekTo(timeMs: Double(timeMs))
  233. }
  234. /// Seeks to a normalized progress value (0.0 – 1.0).
  235. /// - Parameter progress: Normalized position.
  236. func seekToProgress(_ progress: Double) {
  237. let clampedProgress = min(max(progress, 0), 1)
  238. seekTo(timeMs: clampedProgress * totalDurationMs)
  239. }
  240. /// Resets playback to the beginning.
  241. func reset() {
  242. pause()
  243. seekTo(timeMs: 0.0)
  244. }
  245. // MARK: - Private Player Coordination & Audio Analysis
  246. private func startPreparedPlayer() {
  247. guard let player else { return }
  248. shouldPlayWhenReady = true
  249. player.play()
  250. isPlaying = true
  251. }
  252. private func performSeek(
  253. to timeMs: Double,
  254. completion: ((Bool) -> Void)? = nil
  255. ) {
  256. guard let player, isPlaybackReady else {
  257. completion?(false)
  258. return
  259. }
  260. let time = CMTime(seconds: timeMs / 1_000, preferredTimescale: 600)
  261. player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { finished in
  262. DispatchQueue.main.async {
  263. completion?(finished)
  264. }
  265. }
  266. }
  267. private func installPlayerObservers(player: AVPlayer, item: AVPlayerItem) {
  268. playbackTimeObserver = player.addPeriodicTimeObserver(
  269. forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
  270. queue: .main
  271. ) { [weak self] time in
  272. guard let self, !self.isScrubbing else { return }
  273. let seconds = CMTimeGetSeconds(time)
  274. guard seconds.isFinite else { return }
  275. self.currentPlaybackTimeMs = min(max(seconds * 1_000, 0), self.totalDurationMs)
  276. self.checkForSilenceAndSkip()
  277. }
  278. playbackEndObserver = NotificationCenter.default.addObserver(
  279. forName: .AVPlayerItemDidPlayToEndTime,
  280. object: item,
  281. queue: .main
  282. ) { [weak self] _ in
  283. guard let self else { return }
  284. self.isPlaying = false
  285. self.shouldPlayWhenReady = false
  286. self.currentPlaybackTimeMs = self.totalDurationMs
  287. }
  288. }
  289. private func removePlayerObservers() {
  290. if let playbackTimeObserver, let player {
  291. player.removeTimeObserver(playbackTimeObserver)
  292. }
  293. playbackTimeObserver = nil
  294. if let playbackEndObserver {
  295. NotificationCenter.default.removeObserver(playbackEndObserver)
  296. }
  297. playbackEndObserver = nil
  298. }
  299. /// Runs silence detection asynchronously.
  300. func analyzeSilence(audioURL: URL?) {
  301. analyzeAudio(audioURL: audioURL)
  302. }
  303. /// Extracts the real waveform and silence ranges in one background pass.
  304. func analyzeAudio(audioURL: URL?) {
  305. audioAnalysisSession?.cancel()
  306. audioAnalysisTask?.cancel()
  307. let targetPath = audioURL?.path ?? activeAudioURL?.path
  308. guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
  309. waveformSamples = []
  310. silentRanges = []
  311. waveformRevision &+= 1
  312. isAnalyzingSilence = false
  313. isAnalyzingAudio = false
  314. return
  315. }
  316. waveformSamples = []
  317. silentRanges = []
  318. waveformRevision &+= 1
  319. isAnalyzingSilence = true
  320. isAnalyzingAudio = true
  321. let session = AudioAnalysisSession(audioURL: url)
  322. audioAnalysisSession = session
  323. session.prioritize(time: currentPlaybackTimeMs / 1_000)
  324. let reference = WeakPlaybackViewModelReference(self)
  325. audioAnalysisTask = Task {
  326. await session.run { update in
  327. await MainActor.run {
  328. guard let owner = reference.value,
  329. owner.audioAnalysisSession === session else {
  330. return
  331. }
  332. owner.applyAnalysisUpdate(update)
  333. }
  334. }
  335. await MainActor.run {
  336. guard let owner = reference.value,
  337. owner.audioAnalysisSession === session else {
  338. return
  339. }
  340. if !Task.isCancelled {
  341. owner.isAnalyzingSilence = false
  342. owner.isAnalyzingAudio = false
  343. }
  344. }
  345. }
  346. }
  347. func cancelAudioAnalysis() {
  348. audioAnalysisSession?.cancel()
  349. audioAnalysisSession = nil
  350. audioAnalysisTask?.cancel()
  351. audioAnalysisTask = nil
  352. isAnalyzingSilence = false
  353. isAnalyzingAudio = false
  354. }
  355. private func applyAnalysisUpdate(_ update: AudioAnalysisProgress) {
  356. if waveformSamples.count != update.totalSampleCount {
  357. waveformSamples = Array(repeating: 0, count: update.totalSampleCount)
  358. }
  359. let lowerBound = min(max(update.sampleOffset, 0), waveformSamples.count)
  360. let upperBound = min(lowerBound + update.waveformSamples.count, waveformSamples.count)
  361. if lowerBound < upperBound {
  362. waveformSamples.replaceSubrange(
  363. lowerBound..<upperBound,
  364. with: update.waveformSamples.prefix(upperBound - lowerBound)
  365. )
  366. }
  367. silentRanges = update.silentRanges
  368. waveformRevision &+= 1
  369. if update.isComplete {
  370. isAnalyzingSilence = false
  371. isAnalyzingAudio = false
  372. }
  373. }
  374. private func checkForSilenceAndSkip() {
  375. guard isSilenceSkipEnabled,
  376. !silentRanges.isEmpty,
  377. !isSilenceSkipSeekInFlight else {
  378. return
  379. }
  380. let currentTimeSec = currentPlaybackTimeMs / 1000.0
  381. for range in silentRanges {
  382. if currentTimeSec >= range.start && currentTimeSec < range.end {
  383. let targetMs = range.end * 1000.0
  384. isSilenceSkipSeekInFlight = true
  385. currentPlaybackTimeMs = targetMs
  386. performSeek(to: targetMs) { [weak self] _ in
  387. self?.isSilenceSkipSeekInFlight = false
  388. }
  389. break
  390. }
  391. }
  392. }
  393. // MARK: - Event Queries
  394. func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] {
  395. events.sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  396. }
  397. func sortedEvents(from session: CelestiaSession) -> [CelestiaTimelineEvent] {
  398. sortedEvents(from: session.events)
  399. }
  400. func filteredEvents(
  401. from events: [CelestiaTimelineEvent],
  402. type: String?
  403. ) -> [CelestiaTimelineEvent] {
  404. let filtered: [CelestiaTimelineEvent]
  405. if let type {
  406. filtered = events.filter { $0.eventType == type }
  407. } else {
  408. filtered = events
  409. }
  410. return filtered.sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  411. }
  412. func nearestEvent(
  413. from events: [CelestiaTimelineEvent],
  414. toleranceMs: Double = 500
  415. ) -> CelestiaTimelineEvent? {
  416. events
  417. .min(by: {
  418. abs(Double($0.relativeTimeMs) - currentPlaybackTimeMs)
  419. < abs(Double($1.relativeTimeMs) - currentPlaybackTimeMs)
  420. })
  421. .flatMap { event in
  422. abs(Double(event.relativeTimeMs) - currentPlaybackTimeMs) <= toleranceMs
  423. ? event
  424. : nil
  425. }
  426. }
  427. }