import Foundation import AVFoundation import Combine import QuartzCore // MARK: - Smart Crossfade Manager // Dual-AVPlayer (A/B) crossfade engine. // // Profile fields (from Companion API): // silence_start = where TRAILING silence begins (crossfade trigger point) // silence_end = where LEADING silence ends (seek-to point for skip) // loudness_lufs = integrated loudness for volume normalization // // Flow: // 1. playSong() → loads item, starts playback IMMEDIATELY, then fetches profile async // 2. Profile arrives → applies LUFS volume, installs boundary observer at silence_start - fade // 3. Boundary fires → beginCrossfade() fades A↓ B↑ via CADisplayLink // 4. Fade completes → swap slots, install new boundary, request next track class SmartCrossfadeManager: ObservableObject { static let shared = SmartCrossfadeManager() // MARK: - Settings (persisted) @Published var isEnabled = false { didSet { UserDefaults.standard.set(isEnabled, forKey: "smart_crossfade_enabled") } } @Published var crossfadeDuration: TimeInterval = 5.0 { didSet { UserDefaults.standard.set(crossfadeDuration, forKey: "smart_crossfade_duration") } } @Published var targetLUFS: Double = -14.0 { didSet { UserDefaults.standard.set(targetLUFS, forKey: "smart_target_lufs") } } @Published var skipSilence: Bool = true { didSet { UserDefaults.standard.set(skipSilence, forKey: "smart_skip_silence") } } // MARK: - State @Published var currentProfile: SmartDJProfile? @Published var nextProfile: SmartDJProfile? @Published var isCrossfading = false // MARK: - Dual Players private(set) var playerA: AVPlayer = AVPlayer() private(set) var playerB: AVPlayer = AVPlayer() private var activeSlot: Slot = .a var activePlayer: AVPlayer { activeSlot == .a ? playerA : playerB } var standbyPlayer: AVPlayer { activeSlot == .a ? playerB : playerA } enum Slot { case a, b } // MARK: - Volume Targets (LUFS-normalized) private var activeMaxVolume: Float = 1.0 private var standbyMaxVolume: Float = 1.0 // MARK: - Observers private var boundaryObserver: Any? private var timeObserver: Any? private var statusObservation: NSKeyValueObservation? // MARK: - Crossfade Animation private var displayLink: CADisplayLink? private var fadeStartTime: CFTimeInterval = 0 private var fadeOutStartVolume: Float = 1.0 private var fadeInTargetVolume: Float = 1.0 private var didHandoffVisualizer = false // MARK: - Track Info (for self-crossfade) private var currentSongURL: URL? private var currentSong: Song? // MARK: - Queue Sync /// The song that was loaded into standby and is now in the active player post-fade. /// AudioPlayer reads this in needsNextTrack to set queueIndex correctly. private(set) var pendingNextSong: Song? /// Set by AudioPlayer.notifyQueueChanged() when a queue mutation arrives mid-fade. /// finalizeCrossfade checks this and re-prepares standby after the swap. var queueChangedDuringFade: Bool = false // MARK: - Profile Cache private var profiles: [String: SmartDJProfile] = [:] private let api = CompanionAPIService.shared // MARK: - Callbacks var needsNextTrack: (() -> Void)? var timeUpdate: ((TimeInterval, TimeInterval) -> Void)? var visualizerHandoff: ((AVPlayer) -> Void)? /// Fires at crossfade midpoint — AudioPlayer updates currentSong, artwork, colors. var songHandoff: ((Song) -> Void)? private init() { isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled") let dur = UserDefaults.standard.double(forKey: "smart_crossfade_duration") crossfadeDuration = dur > 0 ? dur : 5.0 let lufs = UserDefaults.standard.double(forKey: "smart_target_lufs") targetLUFS = lufs < 0 ? lufs : -14.0 skipSilence = UserDefaults.standard.object(forKey: "smart_skip_silence") as? Bool ?? true } // ═══════════════════════════════════════════════════════ // MARK: - PUBLIC API // ═══════════════════════════════════════════════════════ /// Load and play a song on the active player. /// Starts playback IMMEDIATELY — profile adjustments arrive async. func playSong(_ song: Song, url: URL) { log("▶ Playing: \(song.title) on \(slotName(activeSlot))") cancelCrossfade() removeAllObservers() currentSong = song currentSongURL = url // Load and play immediately — don't wait for profile let asset = AVURLAsset(url: url) let item = AVPlayerItem(asset: asset) activePlayer.replaceCurrentItem(with: item) activePlayer.volume = 1.0 activeMaxVolume = 1.0 activePlayer.play() // Install time observer right away for seek bar installTimeObserver() // Fetch profile async — apply adjustments when it arrives Task { @MainActor [weak self] in guard let self else { return } let profile = await self.fetchProfile(for: song) self.currentProfile = profile // Apply LUFS normalization self.activeMaxVolume = self.lufsToVolume(profile?.loudnessLUFS) // Only adjust volume if not already crossfading if !self.isCrossfading { self.activePlayer.volume = self.activeMaxVolume } // Skip leading silence (silenceEnd = where leading silence ends) if self.skipSilence, let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { let current = self.activePlayer.currentTime().seconds // Only seek if we're still near the start if current < silEnd + 1.0 { await self.activePlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000)) self.log("Skipped \(String(format: "%.1f", silEnd))s leading silence") } } // Install boundary observer for crossfade trigger self.installCrossfadeTrigger(profile: profile) } } /// Pre-load the next song into standby (paused, vol 0, seeked past leading silence). /// Skipped if a crossfade is in progress — finalizeCrossfade will call needsNextTrack /// which calls prepareNextForCrossfade, picking up any queue changes at that point. func prepareNext(_ song: Song, url: URL) { guard !isCrossfading else { log("⏭ prepareNext skipped mid-fade — will pick up \(song.title) after swap") return } log("⏳ Preparing: \(song.title) in \(slotName(activeSlot == .a ? .b : .a))") pendingNextSong = song let asset = AVURLAsset(url: url) let item = AVPlayerItem(asset: asset) standbyPlayer.replaceCurrentItem(with: item) standbyPlayer.volume = 0.0 standbyPlayer.pause() Task { @MainActor [weak self] in guard let self else { return } let profile = await self.fetchProfile(for: song) self.nextProfile = profile self.standbyMaxVolume = self.lufsToVolume(profile?.loudnessLUFS) // Wait for item to be ready before seeking if let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { let seekTarget = CMTime(seconds: silEnd, preferredTimescale: 1000) let standby = self.standbyPlayer let observation = item.observe(\.status, options: [.new]) { observedItem, _ in guard observedItem.status == .readyToPlay else { return } DispatchQueue.main.async { standby.seek(to: seekTarget) } } self.statusObservation = observation } } } func pause() { activePlayer.pause() if isCrossfading { standbyPlayer.pause() } } func resume() { activePlayer.play() if isCrossfading { standbyPlayer.play() } // Reinstall the time observer if it was removed by suspendForBackground() // (happens when app is backgrounded while paused then brought back to foreground) if timeObserver == nil { installTimeObserver() } } func seek(to time: TimeInterval) { let cmTime = CMTime(seconds: time, preferredTimescale: 1000) // Remove existing boundary observer before seeking // to prevent it firing during the seek operation removeBoundaryObserver() activePlayer.seek(to: cmTime) { [weak self] finished in guard finished, let self else { return } // Reinstall boundary observer AFTER seek completes // Only if we haven't seeked past the trigger point DispatchQueue.main.async { if !self.isCrossfading { self.installCrossfadeTrigger(profile: self.currentProfile) } } } } func stop() { cancelCrossfade() removeAllObservers() playerA.pause() playerA.replaceCurrentItem(with: nil) playerB.pause() playerB.replaceCurrentItem(with: nil) activeSlot = .a currentProfile = nil nextProfile = nil currentSong = nil currentSongURL = nil activeMaxVolume = 1.0 standbyMaxVolume = 1.0 } // ═══════════════════════════════════════════════════════ // MARK: - LUFS → Volume // ═══════════════════════════════════════════════════════ private func lufsToVolume(_ lufs: Double?) -> Float { guard let lufs = lufs, lufs < 0 else { return 1.0 } let gainDB = targetLUFS - lufs let linear = pow(10.0, gainDB / 20.0) let clamped = Float(min(max(linear, 0.05), 1.0)) log("LUFS \(String(format: "%.1f", lufs)) → vol \(String(format: "%.3f", clamped))") return clamped } // ═══════════════════════════════════════════════════════ // MARK: - CROSSFADE TRIGGER // ═══════════════════════════════════════════════════════ /// Install a boundary time observer to trigger crossfade. /// Uses silenceStart (trailing silence begin) as the end-of-content marker. /// Falls back to duration - crossfadeDuration if no profile. private func installCrossfadeTrigger(profile: SmartDJProfile?) { removeBoundaryObserver() guard let item = activePlayer.currentItem else { return } let duration = item.duration.seconds // If duration isn't ready yet, poll until it is guard duration.isFinite, duration > 0 else { let pollObs = activePlayer.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] _ in guard let self else { return } let dur = self.activePlayer.currentItem?.duration.seconds ?? 0 if dur.isFinite, dur > 0 { // Duration ready — remove poll, install real trigger if let obs = self.boundaryObserver { self.activePlayer.removeTimeObserver(obs) self.boundaryObserver = nil } self.installCrossfadeTriggerWithDuration(dur, profile: profile) } } boundaryObserver = pollObs return } installCrossfadeTriggerWithDuration(duration, profile: profile) } private func installCrossfadeTriggerWithDuration(_ duration: TimeInterval, profile: SmartDJProfile?) { // silenceStart = where trailing silence begins let silenceStart = profile?.silenceStart ?? 0 let contentEnd: TimeInterval // Valid if it's in the second half of the track if silenceStart > duration * 0.5 && silenceStart <= duration { contentEnd = silenceStart } else { // No valid trailing silence — use full duration contentEnd = duration } // Trigger = content end minus crossfade duration (min 1s from start) let triggerTime = max(1.0, contentEnd - crossfadeDuration) // Don't install if we've already seeked past the trigger let currentPos = activePlayer.currentTime().seconds if currentPos.isFinite && currentPos >= triggerTime { log("⏭ Skipping trigger — already past \(String(format: "%.1f", triggerTime))s") return } log("🎯 Trigger at \(String(format: "%.1f", triggerTime))s " + "(content \(String(format: "%.1f", contentEnd))s, " + "dur \(String(format: "%.1f", duration))s)") let boundary = CMTime(seconds: triggerTime, preferredTimescale: 1000) boundaryObserver = activePlayer.addBoundaryTimeObserver( forTimes: [NSValue(time: boundary)], queue: .main ) { [weak self] in self?.beginCrossfade() } } private func removeBoundaryObserver() { if let obs = boundaryObserver { activePlayer.removeTimeObserver(obs) boundaryObserver = nil } } // ═══════════════════════════════════════════════════════ // MARK: - TIME OBSERVER // ═══════════════════════════════════════════════════════ private func installTimeObserver() { removeTimeObserver() let interval = CMTime(seconds: 0.1, preferredTimescale: 600) timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self else { return } // During crossfade, report from the INCOMING player immediately. // The user hears the new song fading in — seek bar should match. let reportPlayer: AVPlayer if self.isCrossfading { reportPlayer = self.standbyPlayer } else { reportPlayer = self.activePlayer } let current = reportPlayer.currentTime().seconds guard current.isFinite else { return } let dur = reportPlayer.currentItem?.duration.seconds ?? 0 if dur.isFinite, dur > 0 { self.timeUpdate?(current, dur) } } } private func removeTimeObserver() { if let obs = timeObserver { activePlayer.removeTimeObserver(obs) timeObserver = nil } } /// Called when app enters background — removes the 10Hz time observer that /// drives @Published currentTime/duration updates and burns main-thread CPU. func suspendForBackground() { removeTimeObserver() displayLink?.invalidate() displayLink = nil } /// Called when app returns to foreground — reinstalls the time observer. func resumeFromBackground() { guard timeObserver == nil else { return } installTimeObserver() } private func removeAllObservers() { removeBoundaryObserver() removeTimeObserver() statusObservation?.invalidate() statusObservation = nil } // ═══════════════════════════════════════════════════════ // MARK: - CROSSFADE EXECUTION // ═══════════════════════════════════════════════════════ private func beginCrossfade() { guard !isCrossfading else { return } // If standby has nothing, load self-crossfade if standbyPlayer.currentItem == nil { guard let url = currentSongURL else { log("⚠️ No standby and no current URL") return } log("🔁 No next track — self-crossfade") let asset = AVURLAsset(url: url) let item = AVPlayerItem(asset: asset) standbyPlayer.replaceCurrentItem(with: item) standbyMaxVolume = activeMaxVolume nextProfile = currentProfile pendingNextSong = currentSong // self-crossfade: "next" == current // Seek past leading silence if skipSilence, let silEnd = currentProfile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 { standbyPlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000)) } } log("🔀 BEGIN (\(String(format: "%.1f", crossfadeDuration))s)") isCrossfading = true didHandoffVisualizer = false fadeOutStartVolume = activePlayer.volume fadeInTargetVolume = standbyMaxVolume standbyPlayer.volume = 0.0 standbyPlayer.play() fadeStartTime = CACurrentMediaTime() displayLink = CADisplayLink(target: self, selector: #selector(crossfadeTick)) displayLink?.add(to: .main, forMode: .common) } @objc private func crossfadeTick() { let elapsed = CACurrentMediaTime() - fadeStartTime let progress = Float(min(elapsed / crossfadeDuration, 1.0)) // Equal-power crossfade let fadeOut = cosf(progress * .pi / 2.0) let fadeIn = sinf(progress * .pi / 2.0) activePlayer.volume = fadeOutStartVolume * fadeOut standbyPlayer.volume = fadeInTargetVolume * fadeIn if progress >= 0.5 && !didHandoffVisualizer { didHandoffVisualizer = true visualizerHandoff?(standbyPlayer) // Transition metadata/artwork/colors at the midpoint so the UI // reflects the incoming song while the audio is still fading. if let incoming = pendingNextSong { songHandoff?(incoming) } } if progress >= 1.0 { finalizeCrossfade() } } private func finalizeCrossfade() { displayLink?.invalidate() displayLink = nil let outgoing = activePlayer outgoing.pause() outgoing.replaceCurrentItem(with: nil) removeAllObservers() activeSlot = (activeSlot == .a) ? .b : .a currentProfile = nextProfile nextProfile = nil activeMaxVolume = standbyMaxVolume standbyMaxVolume = 1.0 activePlayer.volume = activeMaxVolume isCrossfading = false log("🔀 DONE → \(slotName(activeSlot))") installTimeObserver() installCrossfadeTrigger(profile: currentProfile) // needsNextTrack reads pendingNextSong to sync queueIndex, then clears it via // prepareNextForCrossfade which sets the new pendingNextSong for the NEXT fade. needsNextTrack?() pendingNextSong = nil // If queue changed while we were fading, needsNextTrack → prepareNextForCrossfade // already ran and picked up the current queue state. Just clear the flag. queueChangedDuringFade = false } private func cancelCrossfade() { displayLink?.invalidate() displayLink = nil isCrossfading = false didHandoffVisualizer = false } // ═══════════════════════════════════════════════════════ // MARK: - PROFILE FETCHING // ═══════════════════════════════════════════════════════ private func fetchProfile(for song: Song) async -> SmartDJProfile? { guard CompanionSettings.shared.smartDJEnabled else { return nil } guard let path = song.path else { return nil } // 1. Memory/disk cache if let cached = profiles[song.id] { return cached } if let cached = SmartDJCache.shared.get(path) { profiles[song.id] = cached return cached } // 2. Companion API (server-side analysis) if CompanionSettings.shared.isEnabled { do { let profile = try await api.fetchProfile(relativePath: path) profiles[song.id] = profile SmartDJCache.shared.store(profile, for: path) log("Profile (API): trailing=\(profile.silenceStart ?? 0)s, " + "leading=\(profile.silenceEnd ?? 0)s, " + "LUFS=\(profile.loudnessLUFS ?? 0)") return profile } catch { log("Profile API failed, trying on-device: \(error.localizedDescription)") } } // 3. On-device analysis fallback — for local/downloaded files only. // Runs OfflineAudioAnalyzer's combined pass to extract silence boundaries // and an approximate integrated loudness in one file read. if let localURL = OfflineManager.shared.localURL(for: song.id) { if let profile = await analyzeOnDevice(song: song, url: localURL) { profiles[song.id] = profile SmartDJCache.shared.store(profile, for: path) return profile } } return nil } /// On-device SmartDJ profile extraction using the combined OfflineAudioAnalyzer pass. /// Returns silence boundaries and approximate LUFS without requiring Companion API. private func analyzeOnDevice(song: Song, url: URL) async -> SmartDJProfile? { log("On-device analysis: \(song.title)") do { let result = try await OfflineAudioAnalyzer.shared.analyzeWithSmartDJ( url: url, pointsCount: 20, // minimal for speed — we only need the SmartDJ data fps: 15.0, // low fps = fewer FFT frames = faster cutoff: 80, extractSmartDJ: true, progress: nil ) let profile = SmartDJProfile( bpm: nil, silenceStart: result.silenceStart, silenceEnd: result.silenceEnd, loudnessLUFS: result.loudnessLUFS ) log("On-device profile: trailing=\(String(format: "%.2f", result.silenceStart ?? 0))s, " + "leading=\(String(format: "%.2f", result.silenceEnd ?? 0))s, " + "LUFS≈\(String(format: "%.1f", result.loudnessLUFS ?? 0))") return profile } catch { log("On-device analysis failed: \(error.localizedDescription)") return nil } } // ═══════════════════════════════════════════════════════ // MARK: - HELPERS // ═══════════════════════════════════════════════════════ func resolveURL(for song: Song) -> URL? { if let localURL = OfflineManager.shared.localURL(for: song.id) { return localURL } let quality = StreamingQuality.shared return ServerManager.shared.client.streamURL( songId: song.id, format: quality.format, maxBitRate: quality.maxBitRate ) } private func slotName(_ slot: Slot) -> String { slot == .a ? "A" : "B" } private func log(_ msg: String) { DebugLogger.shared.log(msg, category: "SmartDJ") } } // MARK: - Notification extension Notification.Name { static let smartDJNeedsNextTrack = Notification.Name("smartDJNeedsNextTrack") }