import AVFoundation import Foundation import Observation import os private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "AudioEngine") /// Core audio playback engine using AVAudioEngine for high-quality output. /// Adapted for iOS with AVAudioSession configuration. @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: - 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 @ObservationIgnored private var playbackGeneration: Int = 0 /// Called on main actor when playback finishes naturally. @ObservationIgnored var onPlaybackFinished: (() -> Void)? // MARK: - Init init() { configureAudioSession() setupAudioChain() observeInterruptions() observeRouteChanges() } deinit { NotificationCenter.default.removeObserver(self) engine.stop() } // MARK: - iOS Audio Session private func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .default, options: []) try session.setActive(true) } catch { print("AudioEngine: Failed to configure audio session: \(error)") } } // MARK: - Audio Chain Setup private func setupAudioChain() { engine.attach(playerNode) engine.attach(eqNode) configureBandEQ() // Connect with nil format — AVAudioEngine will negotiate the format // when we schedule a buffer or segment 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 playerNode.stop() isPlaying = false let url = track.fileURL guard FileManager.default.fileExists(atPath: url.path) else { throw AudioEngineError.fileNotFound(url.lastPathComponent) } // Reset OGG state oggBuffer = nil isOGG = false audioFile = nil var didDecodeBuffer = false if OGGDecoder.isOGGFile(url) { // Decode OGG Vorbis to PCM buffer logger.notice("Loading OGG file: \(url.lastPathComponent)") let (buffer, format) = try OGGDecoder.decode(url: url) oggBuffer = buffer isOGG = true audioLengthFrames = AVAudioFramePosition(buffer.frameLength) audioSampleRate = format.sampleRate didDecodeBuffer = true } #if !DISABLE_OPUS if !didDecodeBuffer && OpusDecoder.isOpusFile(url) { // Decode Opus to PCM buffer logger.notice("Loading Opus file: \(url.lastPathComponent)") let (buffer, format) = try OpusDecoder.decode(url: url) oggBuffer = buffer isOGG = true // reuse the same buffer playback path audioLengthFrames = AVAudioFramePosition(buffer.frameLength) audioSampleRate = format.sampleRate didDecodeBuffer = true } #endif if !didDecodeBuffer { logger.notice("Loading audio file: \(url.lastPathComponent)") audioFile = try AVAudioFile(forReading: url) guard let file = audioFile else { return } audioLengthFrames = file.length audioSampleRate = file.processingFormat.sampleRate } duration = Double(audioLengthFrames) / audioSampleRate logger.notice("Loaded: duration=\(self.duration)s, sampleRate=\(self.audioSampleRate), frames=\(self.audioLengthFrames)") seekFrame = 0 currentTime = 0 currentTrack = track } func play() { guard audioFile != nil || oggBuffer != nil else { return } // Ensure audio session is active try? AVAudioSession.sharedInstance().setActive(true) 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 } 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 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 playerNode.stop() seekFrame = AVAudioFramePosition(time * audioSampleRate) seekFrame = max(0, min(seekFrame, audioLengthFrames)) currentTime = Double(seekFrame) / audioSampleRate if wasPlaying { play() } 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 >= 0, band < eqNode.bands.count else { return } eqNode.bands[band].gain = gain } // MARK: - Time Tracking 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() { isPlaying = false seekFrame = 0 currentTime = duration if let track = currentTrack { track.playCount += 1 track.lastPlayed = Date() } onPlaybackFinished?() } // MARK: - Interruption Handling (phone calls, Siri, etc.) private func observeInterruptions() { NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance(), queue: nil ) { [weak self] notification in Task { @MainActor in self?.handleInterruption(notification) } } } private func handleInterruption(_ notification: Notification) { guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: // Pause playback when interrupted (phone call, etc.) if isPlaying { pause() } case .ended: guard let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { play() } @unknown default: break } } // MARK: - Route Change Handling (headphones unplug, etc.) private func observeRouteChanges() { NotificationCenter.default.addObserver( forName: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance(), queue: nil ) { [weak self] notification in Task { @MainActor in self?.handleRouteChange(notification) } } } private func handleRouteChange(_ notification: Notification) { guard let info = notification.userInfo, let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } // Pause when headphones are unplugged (standard iOS behavior) if reason == .oldDeviceUnavailable { if isPlaying { pause() } } } } // MARK: - Errors enum AudioEngineError: LocalizedError { case fileNotFound(String) var errorDescription: String? { switch self { case .fileNotFound(let name): return "Audio file not found: \(name)" } } }