import AVFoundation import Foundation import Observation /// Core audio playback engine using AVAudioEngine for high-quality output /// with real-time sample access for visualization. @MainActor @Observable final class AudioEngine { // MARK: - State var isPlaying = false var currentTime: TimeInterval = 0 var duration: TimeInterval = 0 var volume: Float = 0.8 { didSet { playerNode.volume = volume } } var currentTrack: Track? // MARK: - Realtime Levels var leftLevel: Float = 0 var rightLevel: Float = 0 // MARK: - Private @ObservationIgnored private let engine = AVAudioEngine() @ObservationIgnored private let playerNode = AVAudioPlayerNode() @ObservationIgnored private let eqNode = AVAudioUnitEQ(numberOfBands: 3) @ObservationIgnored private var audioFile: AVAudioFile? @ObservationIgnored private var oggBuffer: AVAudioPCMBuffer? // For OGG playback @ObservationIgnored private var isOGG = false @ObservationIgnored private var seekFrame: AVAudioFramePosition = 0 @ObservationIgnored private var audioLengthFrames: AVAudioFramePosition = 0 @ObservationIgnored private var audioSampleRate: Double = 44100 @ObservationIgnored private var isSeeking = false /// Incremented each time we start/stop/load. Completion handlers compare against this /// to know if they should trigger auto-advance or if playback was interrupted. @ObservationIgnored private var playbackGeneration: Int = 0 /// Called on main actor when playback finishes naturally. @ObservationIgnored var onPlaybackFinished: (() -> Void)? // MARK: - Init init() { setupAudioChain() observeAudioRouteChanges() } deinit { NotificationCenter.default.removeObserver(self) engine.stop() } // MARK: - Audio Chain Setup private func setupAudioChain() { engine.attach(playerNode) engine.attach(eqNode) // Set up 3-band EQ: Low, Mid, High configureBandEQ() // Chain: PlayerNode → EQ → MainMixer → Output // Use nil format — AVAudioEngine will negotiate when we schedule a segment/buffer let mainMixer = engine.mainMixerNode engine.connect(playerNode, to: eqNode, format: nil) engine.connect(eqNode, to: mainMixer, format: nil) engine.prepare() } private func configureBandEQ() { guard eqNode.bands.count >= 3 else { return } let low = eqNode.bands[0] low.filterType = .lowShelf low.frequency = 100 low.bandwidth = 1.0 low.gain = 0 low.bypass = false let mid = eqNode.bands[1] mid.filterType = .parametric mid.frequency = 1000 mid.bandwidth = 1.0 mid.gain = 0 mid.bypass = false let high = eqNode.bands[2] high.filterType = .highShelf high.frequency = 10000 high.bandwidth = 1.0 high.gain = 0 high.bypass = false } // MARK: - Playback Controls func loadTrack(_ track: Track) throws { playbackGeneration += 1 // Invalidate any pending completion handlers playerNode.stop() isPlaying = false let url = track.fileURL // Reset OGG state oggBuffer = nil isOGG = false audioFile = nil if OGGDecoder.isOGGFile(url) { // Decode OGG to PCM buffer let (buffer, format) = try OGGDecoder.decode(url: url) oggBuffer = buffer isOGG = true audioLengthFrames = AVAudioFramePosition(buffer.frameLength) audioSampleRate = format.sampleRate } else { audioFile = try AVAudioFile(forReading: url) guard let file = audioFile else { return } audioLengthFrames = file.length audioSampleRate = file.processingFormat.sampleRate } duration = Double(audioLengthFrames) / audioSampleRate seekFrame = 0 currentTime = 0 currentTrack = track } func play() { guard audioFile != nil || oggBuffer != nil else { return } if !engine.isRunning { do { try engine.start() } catch { print("AudioEngine: Failed to start engine: \(error)") return } } let remainingFrames = AVAudioFrameCount(audioLengthFrames - seekFrame) guard remainingFrames > 0 else { return } // Capture the current generation so the completion handler can check it let gen = playbackGeneration let expectedDuration = Double(remainingFrames) / audioSampleRate if isOGG, let fullBuffer = oggBuffer { // OGG: schedule a slice of the decoded buffer from seekFrame guard let format = fullBuffer.format as AVAudioFormat?, let sliceBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: remainingFrames) else { return } // Copy the relevant portion of the buffer let channels = Int(format.channelCount) for ch in 0.. 0.5 else { return } self.playbackDidFinish() } } } else if let file = audioFile { // Standard file-based playback playerNode.scheduleSegment( file, startingFrame: seekFrame, frameCount: remainingFrames, at: nil, completionCallbackType: .dataPlayedBack ) { [weak self] _ in Task { @MainActor in guard let self else { return } guard self.playbackGeneration == gen else { return } guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return } self.playbackDidFinish() } } } playerNode.play() isPlaying = true } func pause() { playerNode.pause() isPlaying = false updateCurrentTime() } func stop() { playbackGeneration += 1 // Invalidate any pending completion handlers playerNode.stop() isPlaying = false seekFrame = 0 currentTime = 0 } func togglePlayPause() { if isPlaying { pause() } else { play() } } func seek(to time: TimeInterval) { let wasPlaying = isPlaying isSeeking = true playbackGeneration += 1 // Invalidate old completion handler playerNode.stop() seekFrame = AVAudioFramePosition(time * audioSampleRate) seekFrame = max(0, min(seekFrame, audioLengthFrames)) currentTime = Double(seekFrame) / audioSampleRate if wasPlaying { play() // This schedules a new segment with a new generation } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.isSeeking = false } } func seek(by delta: TimeInterval) { let newTime = max(0, min(currentTime + delta, duration)) seek(to: newTime) } // MARK: - EQ Controls func setEQ(band: Int, gain: Float) { guard band < eqNode.bands.count else { return } eqNode.bands[band].gain = gain } // MARK: - Time Tracking /// Called externally (by PlayerViewModel sync timer) to update current time. func updateCurrentTime() { guard !isSeeking else { return } guard isPlaying else { return } guard let nodeTime = playerNode.lastRenderTime, nodeTime.isSampleTimeValid, let playerTime = playerNode.playerTime(forNodeTime: nodeTime), playerTime.sampleTime >= 0 else { return } let newTime = Double(seekFrame + playerTime.sampleTime) / audioSampleRate currentTime = max(0, min(newTime, duration)) } private func playbackDidFinish() { // This only runs if playbackGeneration matched (checked in the closure) isPlaying = false seekFrame = 0 currentTime = duration // Update play count if let track = currentTrack { track.playCount += 1 track.lastPlayed = Date() } // Notify listener (auto-advance) onPlaybackFinished?() } // MARK: - Audio Route Change Handling private func observeAudioRouteChanges() { // AVAudioEngine posts this when the audio hardware configuration changes // (output device change, AirPlay connect/disconnect, headphones plug/unplug) NotificationCenter.default.addObserver( forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil ) { [weak self] _ in Task { @MainActor in self?.handleConfigurationChange() } } } private func handleConfigurationChange() { let wasPlaying = isPlaying let savedTime = currentTime // Engine has been stopped by the system — we need to re-setup isPlaying = false // Check if this is a "device removed" scenario (AirPlay disconnect) // In that case, the engine's output node changes back to default // We detect this by checking if the engine is still running let engineWasRunning = engine.isRunning // Re-setup the audio chain since the hardware config changed setupAudioChain() if wasPlaying && (audioFile != nil || oggBuffer != nil) { // Only auto-resume if the engine was running (device switch, not removal) // For AirPlay disconnect, we pause instead if engineWasRunning { // Device switched (e.g. to AirPlay) — resume from same position seekFrame = AVAudioFramePosition(savedTime * audioSampleRate) seekFrame = max(0, min(seekFrame, audioLengthFrames)) currentTime = savedTime play() } else { // Device removed (AirPlay disconnect) — stay paused at current position seekFrame = AVAudioFramePosition(savedTime * audioSampleRate) seekFrame = max(0, min(seekFrame, audioLengthFrames)) currentTime = savedTime } } } }