PlaybackViewModel.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  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. /// Total session duration in milliseconds.
  15. var totalDurationMs: Double = 0
  16. // MARK: - Silence Skip State
  17. /// Whether auto-skip silence is enabled
  18. var isSilenceSkipEnabled: Bool = false
  19. /// Whether background silence analysis is active
  20. var isAnalyzingSilence: Bool = false
  21. /// Detected silent intervals
  22. var silentRanges: [SilenceRange] = []
  23. // MARK: - Audio Player Private State
  24. private var audioPlayer: AVAudioPlayer?
  25. private var activeAudioURL: URL?
  26. // MARK: - Computed Properties
  27. /// Formats the current playback position as `MM:SS`.
  28. var currentTimeFormatted: String {
  29. let totalSeconds = Int(currentPlaybackTimeMs / 1000)
  30. let minutes = totalSeconds / 60
  31. let seconds = totalSeconds % 60
  32. return String(format: "%02d:%02d", minutes, seconds)
  33. }
  34. /// Total duration formatted as `MM:SS`.
  35. var totalTimeFormatted: String {
  36. let totalSeconds = Int(totalDurationMs / 1000)
  37. let minutes = totalSeconds / 60
  38. let seconds = totalSeconds % 60
  39. return String(format: "%02d:%02d", minutes, seconds)
  40. }
  41. /// Playback progress as a normalized value (0.0 – 1.0).
  42. var progress: Double {
  43. guard totalDurationMs > 0 else { return 0 }
  44. return min(max(currentPlaybackTimeMs / totalDurationMs, 0), 1)
  45. }
  46. // MARK: - Private
  47. /// Tick timer that advances playback position at ~50ms intervals.
  48. private var playbackTimer: Timer?
  49. /// Tick interval in seconds (≈20 ticks/sec for smooth scrubber movement).
  50. private let tickInterval: TimeInterval = 0.05
  51. // MARK: - Init / Deinit
  52. override init() {
  53. super.init()
  54. }
  55. deinit {
  56. playbackTimer?.invalidate()
  57. audioPlayer?.stop()
  58. }
  59. // MARK: - Player Preparation
  60. /// Prepares the audio player for the given file path or generates a sample audio fallback.
  61. func preparePlayer(path: String?, durationMs: Double = 0) {
  62. stopPlaybackTimer()
  63. audioPlayer?.stop()
  64. audioPlayer = nil
  65. guard let targetURL = AudioPathHelper.resolveURL(for: path) else {
  66. print("[PlaybackViewModel] No valid audio file found for path: \(path ?? "nil")")
  67. if durationMs > 0 {
  68. self.totalDurationMs = durationMs
  69. }
  70. return
  71. }
  72. self.activeAudioURL = targetURL
  73. setupAudioSession()
  74. do {
  75. let player = try AVAudioPlayer(contentsOf: targetURL)
  76. player.delegate = self
  77. player.prepareToPlay()
  78. player.volume = 1.0
  79. self.audioPlayer = player
  80. if durationMs <= 0 {
  81. self.totalDurationMs = player.duration * 1000.0
  82. } else {
  83. self.totalDurationMs = durationMs
  84. }
  85. } catch {
  86. print("[PlaybackViewModel] Failed to initialize AVAudioPlayer: \(error.localizedDescription)")
  87. }
  88. }
  89. private func setupAudioSession() {
  90. let session = AVAudioSession.sharedInstance()
  91. do {
  92. try session.setCategory(.playback, mode: .default, options: [.defaultToSpeaker])
  93. try session.setActive(true)
  94. } catch {
  95. print("[PlaybackViewModel] Failed to setup AVAudioSession: \(error.localizedDescription)")
  96. }
  97. }
  98. // MARK: - Playback Controls
  99. /// Starts or resumes playback from the current position.
  100. func play() {
  101. guard !isPlaying else { return }
  102. setupAudioSession()
  103. if let player = audioPlayer {
  104. player.currentTime = currentPlaybackTimeMs / 1000.0
  105. player.play()
  106. }
  107. isPlaying = true
  108. startPlaybackTimer()
  109. }
  110. /// Pauses playback at the current position.
  111. func pause() {
  112. guard isPlaying else { return }
  113. audioPlayer?.pause()
  114. isPlaying = false
  115. stopPlaybackTimer()
  116. }
  117. /// Toggles between playing and paused states.
  118. func togglePlayback() {
  119. if isPlaying {
  120. pause()
  121. } else {
  122. play()
  123. }
  124. }
  125. /// Seeks to a specific position in the timeline.
  126. /// - Parameter timeMs: The target position in milliseconds.
  127. func seekTo(timeMs: Double) {
  128. let target = min(max(timeMs, 0), totalDurationMs)
  129. currentPlaybackTimeMs = target
  130. if let player = audioPlayer {
  131. player.currentTime = target / 1000.0
  132. }
  133. }
  134. /// Convenience overload accepting Int64.
  135. func seekTo(timeMs: Int64) {
  136. seekTo(timeMs: Double(timeMs))
  137. }
  138. /// Seeks to a normalized progress value (0.0 – 1.0).
  139. /// - Parameter progress: Normalized position.
  140. func seekToProgress(_ progress: Double) {
  141. let clampedProgress = min(max(progress, 0), 1)
  142. seekTo(timeMs: clampedProgress * totalDurationMs)
  143. }
  144. /// Resets playback to the beginning.
  145. func reset() {
  146. pause()
  147. seekTo(timeMs: 0.0)
  148. }
  149. // MARK: - AVAudioPlayerDelegate
  150. func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
  151. isPlaying = false
  152. currentPlaybackTimeMs = totalDurationMs
  153. stopPlaybackTimer()
  154. }
  155. // MARK: - Private Timer & Silence Detection
  156. private func startPlaybackTimer() {
  157. playbackTimer?.invalidate()
  158. playbackTimer = Timer.scheduledTimer(
  159. withTimeInterval: tickInterval,
  160. repeats: true
  161. ) { [weak self] _ in
  162. guard let self else { return }
  163. self.tick()
  164. }
  165. if let timer = playbackTimer {
  166. RunLoop.main.add(timer, forMode: .common)
  167. }
  168. }
  169. private func stopPlaybackTimer() {
  170. playbackTimer?.invalidate()
  171. playbackTimer = nil
  172. }
  173. /// Runs silence detection asynchronously.
  174. func analyzeSilence(audioURL: URL?) {
  175. let targetPath = audioURL?.path ?? activeAudioURL?.path
  176. guard let url = AudioPathHelper.resolveURL(for: targetPath) else {
  177. self.silentRanges = [
  178. SilenceRange(start: 5.0, end: 12.0),
  179. SilenceRange(start: 25.0, end: 32.0)
  180. ]
  181. return
  182. }
  183. isAnalyzingSilence = true
  184. Task {
  185. let ranges = await SilenceDetector.detectSilence(in: url)
  186. await MainActor.run {
  187. self.silentRanges = ranges
  188. self.isAnalyzingSilence = false
  189. }
  190. }
  191. }
  192. private func checkForSilenceAndSkip() {
  193. guard isSilenceSkipEnabled && !silentRanges.isEmpty else { return }
  194. let currentTimeSec = currentPlaybackTimeMs / 1000.0
  195. for range in silentRanges {
  196. if currentTimeSec >= range.start && currentTimeSec < range.end {
  197. let targetMs = range.end * 1000.0
  198. seekTo(timeMs: targetMs)
  199. print("[PlaybackViewModel] Auto-skipped silence from \(range.start)s to \(range.end)s")
  200. break
  201. }
  202. }
  203. }
  204. /// Advances playback by one tick interval, syncing with audioPlayer if active.
  205. private func tick() {
  206. if let player = audioPlayer, player.isPlaying {
  207. currentPlaybackTimeMs = player.currentTime * 1000.0
  208. } else {
  209. currentPlaybackTimeMs += tickInterval * 1000
  210. }
  211. checkForSilenceAndSkip()
  212. if currentPlaybackTimeMs >= totalDurationMs {
  213. currentPlaybackTimeMs = totalDurationMs
  214. pause()
  215. }
  216. }
  217. // MARK: - Event Queries
  218. func sortedEvents(from events: [CelestiaTimelineEvent]) -> [CelestiaTimelineEvent] {
  219. events.sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  220. }
  221. func sortedEvents(from session: CelestiaSession) -> [CelestiaTimelineEvent] {
  222. sortedEvents(from: session.events)
  223. }
  224. func filteredEvents(
  225. from events: [CelestiaTimelineEvent],
  226. type: String?
  227. ) -> [CelestiaTimelineEvent] {
  228. let filtered: [CelestiaTimelineEvent]
  229. if let type {
  230. filtered = events.filter { $0.eventType == type }
  231. } else {
  232. filtered = events
  233. }
  234. return filtered.sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  235. }
  236. func nearestEvent(
  237. from events: [CelestiaTimelineEvent],
  238. toleranceMs: Double = 500
  239. ) -> CelestiaTimelineEvent? {
  240. events
  241. .min(by: {
  242. abs(Double($0.relativeTimeMs) - currentPlaybackTimeMs)
  243. < abs(Double($1.relativeTimeMs) - currentPlaybackTimeMs)
  244. })
  245. .flatMap { event in
  246. abs(Double(event.relativeTimeMs) - currentPlaybackTimeMs) <= toleranceMs
  247. ? event
  248. : nil
  249. }
  250. }
  251. // MARK: - Helper Tone Generator
  252. private func generateFallbackAudioFile(durationSeconds: Double) -> URL? {
  253. let tempDir = FileManager.default.temporaryDirectory
  254. let fileURL = tempDir.appendingPathComponent("sample_playback.wav")
  255. if FileManager.default.fileExists(atPath: fileURL.path) {
  256. return fileURL
  257. }
  258. let sampleRate: Double = 22050.0
  259. let numSamples = Int(sampleRate * durationSeconds)
  260. let numChannels: UInt16 = 1
  261. let bitsPerSample: UInt16 = 16
  262. let byteRate = UInt32(Double(sampleRate) * Double(numChannels) * Double(bitsPerSample / 8))
  263. let blockAlign = UInt16(numChannels * (bitsPerSample / 8))
  264. let dataSize = UInt32(numSamples * Int(blockAlign))
  265. let chunkSize = 36 + dataSize
  266. var data = Data()
  267. // RIFF header
  268. data.append(contentsOf: Array("RIFF".utf8))
  269. data.append(withUnsafeBytes(of: chunkSize.littleEndian) { Data($0) })
  270. data.append(contentsOf: Array("WAVE".utf8))
  271. // fmt chunk
  272. data.append(contentsOf: Array("fmt ".utf8))
  273. data.append(withUnsafeBytes(of: UInt32(16).littleEndian) { Data($0) })
  274. data.append(withUnsafeBytes(of: UInt16(1).littleEndian) { Data($0) })
  275. data.append(withUnsafeBytes(of: numChannels.littleEndian) { Data($0) })
  276. data.append(withUnsafeBytes(of: UInt32(sampleRate).littleEndian) { Data($0) })
  277. data.append(withUnsafeBytes(of: byteRate.littleEndian) { Data($0) })
  278. data.append(withUnsafeBytes(of: blockAlign.littleEndian) { Data($0) })
  279. data.append(withUnsafeBytes(of: bitsPerSample.littleEndian) { Data($0) })
  280. // data chunk
  281. data.append(contentsOf: Array("data".utf8))
  282. data.append(withUnsafeBytes(of: dataSize.littleEndian) { Data($0) })
  283. let frequency1: Double = 440.0
  284. let frequency2: Double = 554.37
  285. for i in 0..<numSamples {
  286. let t = Double(i) / sampleRate
  287. let sampleVal1 = sin(2.0 * .pi * frequency1 * t)
  288. let sampleVal2 = sin(2.0 * .pi * frequency2 * t)
  289. let envelope = min(1.0, min(t * 2.0, (durationSeconds - t) * 2.0))
  290. let combined = (sampleVal1 * 0.4 + sampleVal2 * 0.3) * envelope
  291. let pcmValue = Int16(clamping: Int(combined * 16384.0))
  292. data.append(withUnsafeBytes(of: pcmValue.littleEndian) { Data($0) })
  293. }
  294. do {
  295. try data.write(to: fileURL)
  296. return fileURL
  297. } catch {
  298. print("[PlaybackViewModel] Failed to write fallback audio WAV: \(error.localizedDescription)")
  299. return nil
  300. }
  301. }
  302. }