AudioEngine.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import AVFoundation
  2. import Foundation
  3. import Observation
  4. import os
  5. private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "AudioEngine")
  6. /// Core audio playback engine using AVAudioEngine for high-quality output.
  7. /// Adapted for iOS with AVAudioSession configuration.
  8. @MainActor
  9. @Observable
  10. final class AudioEngine {
  11. // MARK: - State
  12. var isPlaying = false
  13. var currentTime: TimeInterval = 0
  14. var duration: TimeInterval = 0
  15. var volume: Float = 0.8 {
  16. didSet { playerNode.volume = volume }
  17. }
  18. var currentTrack: Track?
  19. // MARK: - Private
  20. @ObservationIgnored private let engine = AVAudioEngine()
  21. @ObservationIgnored private let playerNode = AVAudioPlayerNode()
  22. @ObservationIgnored private let eqNode = AVAudioUnitEQ(numberOfBands: 3)
  23. @ObservationIgnored private var audioFile: AVAudioFile?
  24. @ObservationIgnored private var oggBuffer: AVAudioPCMBuffer? // For OGG playback
  25. @ObservationIgnored private var isOGG = false
  26. @ObservationIgnored private var seekFrame: AVAudioFramePosition = 0
  27. @ObservationIgnored private var audioLengthFrames: AVAudioFramePosition = 0
  28. @ObservationIgnored private var audioSampleRate: Double = 44100
  29. @ObservationIgnored private var isSeeking = false
  30. @ObservationIgnored private var playbackGeneration: Int = 0
  31. /// Called on main actor when playback finishes naturally.
  32. @ObservationIgnored var onPlaybackFinished: (() -> Void)?
  33. // MARK: - Init
  34. init() {
  35. configureAudioSession()
  36. setupAudioChain()
  37. observeInterruptions()
  38. observeRouteChanges()
  39. }
  40. deinit {
  41. NotificationCenter.default.removeObserver(self)
  42. engine.stop()
  43. }
  44. // MARK: - iOS Audio Session
  45. private func configureAudioSession() {
  46. let session = AVAudioSession.sharedInstance()
  47. do {
  48. try session.setCategory(.playback, mode: .default, options: [])
  49. try session.setActive(true)
  50. } catch {
  51. print("AudioEngine: Failed to configure audio session: \(error)")
  52. }
  53. }
  54. // MARK: - Audio Chain Setup
  55. private func setupAudioChain() {
  56. engine.attach(playerNode)
  57. engine.attach(eqNode)
  58. configureBandEQ()
  59. // Connect with nil format — AVAudioEngine will negotiate the format
  60. // when we schedule a buffer or segment
  61. let mainMixer = engine.mainMixerNode
  62. engine.connect(playerNode, to: eqNode, format: nil)
  63. engine.connect(eqNode, to: mainMixer, format: nil)
  64. engine.prepare()
  65. }
  66. private func configureBandEQ() {
  67. guard eqNode.bands.count >= 3 else { return }
  68. let low = eqNode.bands[0]
  69. low.filterType = .lowShelf
  70. low.frequency = 100
  71. low.bandwidth = 1.0
  72. low.gain = 0
  73. low.bypass = false
  74. let mid = eqNode.bands[1]
  75. mid.filterType = .parametric
  76. mid.frequency = 1000
  77. mid.bandwidth = 1.0
  78. mid.gain = 0
  79. mid.bypass = false
  80. let high = eqNode.bands[2]
  81. high.filterType = .highShelf
  82. high.frequency = 10000
  83. high.bandwidth = 1.0
  84. high.gain = 0
  85. high.bypass = false
  86. }
  87. // MARK: - Playback Controls
  88. func loadTrack(_ track: Track) throws {
  89. playbackGeneration += 1
  90. playerNode.stop()
  91. isPlaying = false
  92. let url = track.fileURL
  93. guard FileManager.default.fileExists(atPath: url.path) else {
  94. throw AudioEngineError.fileNotFound(url.lastPathComponent)
  95. }
  96. // Reset OGG state
  97. oggBuffer = nil
  98. isOGG = false
  99. audioFile = nil
  100. var didDecodeBuffer = false
  101. if OGGDecoder.isOGGFile(url) {
  102. // Decode OGG Vorbis to PCM buffer
  103. logger.notice("Loading OGG file: \(url.lastPathComponent)")
  104. let (buffer, format) = try OGGDecoder.decode(url: url)
  105. oggBuffer = buffer
  106. isOGG = true
  107. audioLengthFrames = AVAudioFramePosition(buffer.frameLength)
  108. audioSampleRate = format.sampleRate
  109. didDecodeBuffer = true
  110. }
  111. #if !DISABLE_OPUS
  112. if !didDecodeBuffer && OpusDecoder.isOpusFile(url) {
  113. // Decode Opus to PCM buffer
  114. logger.notice("Loading Opus file: \(url.lastPathComponent)")
  115. let (buffer, format) = try OpusDecoder.decode(url: url)
  116. oggBuffer = buffer
  117. isOGG = true // reuse the same buffer playback path
  118. audioLengthFrames = AVAudioFramePosition(buffer.frameLength)
  119. audioSampleRate = format.sampleRate
  120. didDecodeBuffer = true
  121. }
  122. #endif
  123. if !didDecodeBuffer {
  124. logger.notice("Loading audio file: \(url.lastPathComponent)")
  125. audioFile = try AVAudioFile(forReading: url)
  126. guard let file = audioFile else { return }
  127. audioLengthFrames = file.length
  128. audioSampleRate = file.processingFormat.sampleRate
  129. }
  130. duration = Double(audioLengthFrames) / audioSampleRate
  131. logger.notice("Loaded: duration=\(self.duration)s, sampleRate=\(self.audioSampleRate), frames=\(self.audioLengthFrames)")
  132. seekFrame = 0
  133. currentTime = 0
  134. currentTrack = track
  135. }
  136. func play() {
  137. guard audioFile != nil || oggBuffer != nil else { return }
  138. // Ensure audio session is active
  139. try? AVAudioSession.sharedInstance().setActive(true)
  140. if !engine.isRunning {
  141. do {
  142. try engine.start()
  143. } catch {
  144. print("AudioEngine: Failed to start engine: \(error)")
  145. return
  146. }
  147. }
  148. let remainingFrames = AVAudioFrameCount(audioLengthFrames - seekFrame)
  149. guard remainingFrames > 0 else { return }
  150. let gen = playbackGeneration
  151. let expectedDuration = Double(remainingFrames) / audioSampleRate
  152. if isOGG, let fullBuffer = oggBuffer {
  153. // OGG: schedule a slice of the decoded buffer from seekFrame
  154. guard let format = fullBuffer.format as AVAudioFormat?,
  155. let sliceBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: remainingFrames) else { return }
  156. // Copy the relevant portion of the buffer
  157. let channels = Int(format.channelCount)
  158. for ch in 0..<channels {
  159. let src = fullBuffer.floatChannelData![ch].advanced(by: Int(seekFrame))
  160. let dst = sliceBuffer.floatChannelData![ch]
  161. dst.update(from: src, count: Int(remainingFrames))
  162. }
  163. sliceBuffer.frameLength = remainingFrames
  164. playerNode.scheduleBuffer(sliceBuffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
  165. Task { @MainActor in
  166. guard let self else { return }
  167. guard self.playbackGeneration == gen else { return }
  168. guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return }
  169. self.playbackDidFinish()
  170. }
  171. }
  172. } else if let file = audioFile {
  173. // Standard file-based playback
  174. playerNode.scheduleSegment(
  175. file,
  176. startingFrame: seekFrame,
  177. frameCount: remainingFrames,
  178. at: nil,
  179. completionCallbackType: .dataPlayedBack
  180. ) { [weak self] _ in
  181. Task { @MainActor in
  182. guard let self else { return }
  183. guard self.playbackGeneration == gen else { return }
  184. guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return }
  185. self.playbackDidFinish()
  186. }
  187. }
  188. }
  189. playerNode.play()
  190. isPlaying = true
  191. }
  192. func pause() {
  193. playerNode.pause()
  194. isPlaying = false
  195. updateCurrentTime()
  196. }
  197. func stop() {
  198. playbackGeneration += 1
  199. playerNode.stop()
  200. isPlaying = false
  201. seekFrame = 0
  202. currentTime = 0
  203. }
  204. func togglePlayPause() {
  205. if isPlaying {
  206. pause()
  207. } else {
  208. play()
  209. }
  210. }
  211. func seek(to time: TimeInterval) {
  212. let wasPlaying = isPlaying
  213. isSeeking = true
  214. playbackGeneration += 1
  215. playerNode.stop()
  216. seekFrame = AVAudioFramePosition(time * audioSampleRate)
  217. seekFrame = max(0, min(seekFrame, audioLengthFrames))
  218. currentTime = Double(seekFrame) / audioSampleRate
  219. if wasPlaying {
  220. play()
  221. }
  222. DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
  223. self?.isSeeking = false
  224. }
  225. }
  226. func seek(by delta: TimeInterval) {
  227. let newTime = max(0, min(currentTime + delta, duration))
  228. seek(to: newTime)
  229. }
  230. // MARK: - EQ Controls
  231. func setEQ(band: Int, gain: Float) {
  232. guard band >= 0, band < eqNode.bands.count else { return }
  233. eqNode.bands[band].gain = gain
  234. }
  235. // MARK: - Time Tracking
  236. func updateCurrentTime() {
  237. guard !isSeeking else { return }
  238. guard isPlaying else { return }
  239. guard let nodeTime = playerNode.lastRenderTime,
  240. nodeTime.isSampleTimeValid,
  241. let playerTime = playerNode.playerTime(forNodeTime: nodeTime),
  242. playerTime.sampleTime >= 0 else { return }
  243. let newTime = Double(seekFrame + playerTime.sampleTime) / audioSampleRate
  244. currentTime = max(0, min(newTime, duration))
  245. }
  246. private func playbackDidFinish() {
  247. isPlaying = false
  248. seekFrame = 0
  249. currentTime = duration
  250. if let track = currentTrack {
  251. track.playCount += 1
  252. track.lastPlayed = Date()
  253. }
  254. onPlaybackFinished?()
  255. }
  256. // MARK: - Interruption Handling (phone calls, Siri, etc.)
  257. private func observeInterruptions() {
  258. NotificationCenter.default.addObserver(
  259. forName: AVAudioSession.interruptionNotification,
  260. object: AVAudioSession.sharedInstance(),
  261. queue: nil
  262. ) { [weak self] notification in
  263. Task { @MainActor in
  264. self?.handleInterruption(notification)
  265. }
  266. }
  267. }
  268. private func handleInterruption(_ notification: Notification) {
  269. guard let info = notification.userInfo,
  270. let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
  271. let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
  272. switch type {
  273. case .began:
  274. // Pause playback when interrupted (phone call, etc.)
  275. if isPlaying {
  276. pause()
  277. }
  278. case .ended:
  279. guard let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
  280. let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
  281. if options.contains(.shouldResume) {
  282. play()
  283. }
  284. @unknown default:
  285. break
  286. }
  287. }
  288. // MARK: - Route Change Handling (headphones unplug, etc.)
  289. private func observeRouteChanges() {
  290. NotificationCenter.default.addObserver(
  291. forName: AVAudioSession.routeChangeNotification,
  292. object: AVAudioSession.sharedInstance(),
  293. queue: nil
  294. ) { [weak self] notification in
  295. Task { @MainActor in
  296. self?.handleRouteChange(notification)
  297. }
  298. }
  299. }
  300. private func handleRouteChange(_ notification: Notification) {
  301. guard let info = notification.userInfo,
  302. let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
  303. let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
  304. // Pause when headphones are unplugged (standard iOS behavior)
  305. if reason == .oldDeviceUnavailable {
  306. if isPlaying {
  307. pause()
  308. }
  309. }
  310. }
  311. }
  312. // MARK: - Errors
  313. enum AudioEngineError: LocalizedError {
  314. case fileNotFound(String)
  315. var errorDescription: String? {
  316. switch self {
  317. case .fileNotFound(let name):
  318. return "Audio file not found: \(name)"
  319. }
  320. }
  321. }