| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- 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, fileURL: URL? = nil) throws {
- playbackGeneration += 1 // Invalidate any pending completion handlers
- playerNode.stop()
- isPlaying = false
- let url = fileURL ?? 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..<channels {
- let src = fullBuffer.floatChannelData![ch].advanced(by: Int(seekFrame))
- let dst = sliceBuffer.floatChannelData![ch]
- dst.update(from: src, count: Int(remainingFrames))
- }
- sliceBuffer.frameLength = remainingFrames
- playerNode.scheduleBuffer(sliceBuffer, 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()
- }
- }
- } 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
- }
- }
- }
- }
|