| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- 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..<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
- 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)"
- }
- }
- }
|