AudioEngine.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import AVFoundation
  2. import Foundation
  3. import Observation
  4. /// Core audio playback engine using AVAudioEngine for high-quality output
  5. /// with real-time sample access for visualization.
  6. @MainActor
  7. @Observable
  8. final class AudioEngine {
  9. // MARK: - State
  10. var isPlaying = false
  11. var currentTime: TimeInterval = 0
  12. var duration: TimeInterval = 0
  13. var volume: Float = 0.8 {
  14. didSet { playerNode.volume = volume }
  15. }
  16. var currentTrack: Track?
  17. // MARK: - Realtime Levels
  18. var leftLevel: Float = 0
  19. var rightLevel: Float = 0
  20. // MARK: - Private
  21. @ObservationIgnored private let engine = AVAudioEngine()
  22. @ObservationIgnored private let playerNode = AVAudioPlayerNode()
  23. @ObservationIgnored private let eqNode = AVAudioUnitEQ(numberOfBands: 3)
  24. @ObservationIgnored private var audioFile: AVAudioFile?
  25. @ObservationIgnored private var oggBuffer: AVAudioPCMBuffer? // For OGG playback
  26. @ObservationIgnored private var isOGG = false
  27. @ObservationIgnored private var seekFrame: AVAudioFramePosition = 0
  28. @ObservationIgnored private var audioLengthFrames: AVAudioFramePosition = 0
  29. @ObservationIgnored private var audioSampleRate: Double = 44100
  30. @ObservationIgnored private var isSeeking = false
  31. /// Incremented each time we start/stop/load. Completion handlers compare against this
  32. /// to know if they should trigger auto-advance or if playback was interrupted.
  33. @ObservationIgnored private var playbackGeneration: Int = 0
  34. /// Called on main actor when playback finishes naturally.
  35. @ObservationIgnored var onPlaybackFinished: (() -> Void)?
  36. // MARK: - Init
  37. init() {
  38. setupAudioChain()
  39. observeAudioRouteChanges()
  40. }
  41. deinit {
  42. NotificationCenter.default.removeObserver(self)
  43. engine.stop()
  44. }
  45. // MARK: - Audio Chain Setup
  46. private func setupAudioChain() {
  47. engine.attach(playerNode)
  48. engine.attach(eqNode)
  49. // Set up 3-band EQ: Low, Mid, High
  50. configureBandEQ()
  51. // Chain: PlayerNode → EQ → MainMixer → Output
  52. // Use nil format — AVAudioEngine will negotiate when we schedule a segment/buffer
  53. let mainMixer = engine.mainMixerNode
  54. engine.connect(playerNode, to: eqNode, format: nil)
  55. engine.connect(eqNode, to: mainMixer, format: nil)
  56. engine.prepare()
  57. }
  58. private func configureBandEQ() {
  59. guard eqNode.bands.count >= 3 else { return }
  60. let low = eqNode.bands[0]
  61. low.filterType = .lowShelf
  62. low.frequency = 100
  63. low.bandwidth = 1.0
  64. low.gain = 0
  65. low.bypass = false
  66. let mid = eqNode.bands[1]
  67. mid.filterType = .parametric
  68. mid.frequency = 1000
  69. mid.bandwidth = 1.0
  70. mid.gain = 0
  71. mid.bypass = false
  72. let high = eqNode.bands[2]
  73. high.filterType = .highShelf
  74. high.frequency = 10000
  75. high.bandwidth = 1.0
  76. high.gain = 0
  77. high.bypass = false
  78. }
  79. // MARK: - Playback Controls
  80. func loadTrack(_ track: Track, fileURL: URL? = nil) throws {
  81. playbackGeneration += 1 // Invalidate any pending completion handlers
  82. playerNode.stop()
  83. isPlaying = false
  84. let url = fileURL ?? track.fileURL
  85. // Reset OGG state
  86. oggBuffer = nil
  87. isOGG = false
  88. audioFile = nil
  89. if OGGDecoder.isOGGFile(url) {
  90. // Decode OGG to PCM buffer
  91. let (buffer, format) = try OGGDecoder.decode(url: url)
  92. oggBuffer = buffer
  93. isOGG = true
  94. audioLengthFrames = AVAudioFramePosition(buffer.frameLength)
  95. audioSampleRate = format.sampleRate
  96. } else {
  97. audioFile = try AVAudioFile(forReading: url)
  98. guard let file = audioFile else { return }
  99. audioLengthFrames = file.length
  100. audioSampleRate = file.processingFormat.sampleRate
  101. }
  102. duration = Double(audioLengthFrames) / audioSampleRate
  103. seekFrame = 0
  104. currentTime = 0
  105. currentTrack = track
  106. }
  107. func play() {
  108. guard audioFile != nil || oggBuffer != nil else { return }
  109. if !engine.isRunning {
  110. do {
  111. try engine.start()
  112. } catch {
  113. print("AudioEngine: Failed to start engine: \(error)")
  114. return
  115. }
  116. }
  117. let remainingFrames = AVAudioFrameCount(audioLengthFrames - seekFrame)
  118. guard remainingFrames > 0 else { return }
  119. // Capture the current generation so the completion handler can check it
  120. let gen = playbackGeneration
  121. let expectedDuration = Double(remainingFrames) / audioSampleRate
  122. if isOGG, let fullBuffer = oggBuffer {
  123. // OGG: schedule a slice of the decoded buffer from seekFrame
  124. guard let format = fullBuffer.format as AVAudioFormat?,
  125. let sliceBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: remainingFrames) else { return }
  126. // Copy the relevant portion of the buffer
  127. let channels = Int(format.channelCount)
  128. for ch in 0..<channels {
  129. let src = fullBuffer.floatChannelData![ch].advanced(by: Int(seekFrame))
  130. let dst = sliceBuffer.floatChannelData![ch]
  131. dst.update(from: src, count: Int(remainingFrames))
  132. }
  133. sliceBuffer.frameLength = remainingFrames
  134. playerNode.scheduleBuffer(sliceBuffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
  135. Task { @MainActor in
  136. guard let self else { return }
  137. guard self.playbackGeneration == gen else { return }
  138. guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return }
  139. self.playbackDidFinish()
  140. }
  141. }
  142. } else if let file = audioFile {
  143. // Standard file-based playback
  144. playerNode.scheduleSegment(
  145. file,
  146. startingFrame: seekFrame,
  147. frameCount: remainingFrames,
  148. at: nil,
  149. completionCallbackType: .dataPlayedBack
  150. ) { [weak self] _ in
  151. Task { @MainActor in
  152. guard let self else { return }
  153. guard self.playbackGeneration == gen else { return }
  154. guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return }
  155. self.playbackDidFinish()
  156. }
  157. }
  158. }
  159. playerNode.play()
  160. isPlaying = true
  161. }
  162. func pause() {
  163. playerNode.pause()
  164. isPlaying = false
  165. updateCurrentTime()
  166. }
  167. func stop() {
  168. playbackGeneration += 1 // Invalidate any pending completion handlers
  169. playerNode.stop()
  170. isPlaying = false
  171. seekFrame = 0
  172. currentTime = 0
  173. }
  174. func togglePlayPause() {
  175. if isPlaying {
  176. pause()
  177. } else {
  178. play()
  179. }
  180. }
  181. func seek(to time: TimeInterval) {
  182. let wasPlaying = isPlaying
  183. isSeeking = true
  184. playbackGeneration += 1 // Invalidate old completion handler
  185. playerNode.stop()
  186. seekFrame = AVAudioFramePosition(time * audioSampleRate)
  187. seekFrame = max(0, min(seekFrame, audioLengthFrames))
  188. currentTime = Double(seekFrame) / audioSampleRate
  189. if wasPlaying {
  190. play() // This schedules a new segment with a new generation
  191. }
  192. DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
  193. self?.isSeeking = false
  194. }
  195. }
  196. func seek(by delta: TimeInterval) {
  197. let newTime = max(0, min(currentTime + delta, duration))
  198. seek(to: newTime)
  199. }
  200. // MARK: - EQ Controls
  201. func setEQ(band: Int, gain: Float) {
  202. guard band < eqNode.bands.count else { return }
  203. eqNode.bands[band].gain = gain
  204. }
  205. // MARK: - Time Tracking
  206. /// Called externally (by PlayerViewModel sync timer) to update current time.
  207. func updateCurrentTime() {
  208. guard !isSeeking else { return }
  209. guard isPlaying else { return }
  210. guard let nodeTime = playerNode.lastRenderTime,
  211. nodeTime.isSampleTimeValid,
  212. let playerTime = playerNode.playerTime(forNodeTime: nodeTime),
  213. playerTime.sampleTime >= 0 else { return }
  214. let newTime = Double(seekFrame + playerTime.sampleTime) / audioSampleRate
  215. currentTime = max(0, min(newTime, duration))
  216. }
  217. private func playbackDidFinish() {
  218. // This only runs if playbackGeneration matched (checked in the closure)
  219. isPlaying = false
  220. seekFrame = 0
  221. currentTime = duration
  222. // Update play count
  223. if let track = currentTrack {
  224. track.playCount += 1
  225. track.lastPlayed = Date()
  226. }
  227. // Notify listener (auto-advance)
  228. onPlaybackFinished?()
  229. }
  230. // MARK: - Audio Route Change Handling
  231. private func observeAudioRouteChanges() {
  232. // AVAudioEngine posts this when the audio hardware configuration changes
  233. // (output device change, AirPlay connect/disconnect, headphones plug/unplug)
  234. NotificationCenter.default.addObserver(
  235. forName: .AVAudioEngineConfigurationChange,
  236. object: engine,
  237. queue: nil
  238. ) { [weak self] _ in
  239. Task { @MainActor in
  240. self?.handleConfigurationChange()
  241. }
  242. }
  243. }
  244. private func handleConfigurationChange() {
  245. let wasPlaying = isPlaying
  246. let savedTime = currentTime
  247. // Engine has been stopped by the system — we need to re-setup
  248. isPlaying = false
  249. // Check if this is a "device removed" scenario (AirPlay disconnect)
  250. // In that case, the engine's output node changes back to default
  251. // We detect this by checking if the engine is still running
  252. let engineWasRunning = engine.isRunning
  253. // Re-setup the audio chain since the hardware config changed
  254. setupAudioChain()
  255. if wasPlaying && (audioFile != nil || oggBuffer != nil) {
  256. // Only auto-resume if the engine was running (device switch, not removal)
  257. // For AirPlay disconnect, we pause instead
  258. if engineWasRunning {
  259. // Device switched (e.g. to AirPlay) — resume from same position
  260. seekFrame = AVAudioFramePosition(savedTime * audioSampleRate)
  261. seekFrame = max(0, min(seekFrame, audioLengthFrames))
  262. currentTime = savedTime
  263. play()
  264. } else {
  265. // Device removed (AirPlay disconnect) — stay paused at current position
  266. seekFrame = AVAudioFramePosition(savedTime * audioSampleRate)
  267. seekFrame = max(0, min(seekFrame, audioLengthFrames))
  268. currentTime = savedTime
  269. }
  270. }
  271. }
  272. }