PlaybackViewModel.swift 12 KB

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