import Foundation import AVFoundation import Combine import MediaPlayer import Accelerate import os #if os(iOS) import UIKit #endif /// Shared audio player with real FFT visualizer for local files class AudioPlayer: NSObject, ObservableObject { static let shared = AudioPlayer() // MARK: - Published State @Published var currentSong: Song? @Published var queue: [Song] = [] @Published var queueIndex: Int = 0 @Published var isPlaying = false // currentTime and duration are NOT @Published — updating them 10x/sec // would cause every view with @EnvironmentObject audioPlayer to re-render, // killing gesture recognizers across the entire app. // Views that need these (NowPlayingView, MiniPlayerBar) poll them directly // via their own Timer/TimelineView. var currentTime: TimeInterval = 0 var duration: TimeInterval = 0 @Published var volume: Float = 0.8 @Published var repeatMode: RepeatMode = .off @Published var shuffleEnabled = false // audioLevels is NOT @Published — avoids 60fps SwiftUI state thrashing // Visualizer pulls directly via AudioPlayer.shared.currentLevels() // No lock needed: writes happen on main thread (Timer callbacks), // reads happen from view body. A one-frame-stale read is imperceptible. nonisolated(unsafe) private var _audioLevels: [Float] = Array(repeating: 0, count: 30) /// Current visualizer levels (called from TimelineView body) func currentLevels() -> [Float] { _audioLevels } /// Write visualizer levels (called from timers/taps on main thread) private func setLevels(_ levels: [Float]) { _audioLevels = levels } @Published var isUsingRealFFT = false @Published var isUsingOfflineVis = false @Published var offlineVisProgress: Float = 0 @Published var sourcePlaylistId: String? @Published var sourcePlaylistName: String? enum RepeatMode { case off, all, one } // MARK: - AVPlayer (for streaming) private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserver: Any? // MARK: - AVAudioEngine (for local files with FFT) private var audioEngine: AVAudioEngine? private var enginePlayerNode: AVAudioPlayerNode? private var engineTimeTimer: Timer? private var engineFile: AVAudioFile? private var engineStartFrame: AVAudioFramePosition = 0 private var scheduleGeneration: Int = 0 private var nowPlayingSyncTimer: Timer? #if os(iOS) /// When true, SmartCrossfadeManager owns playback instead of the local player/engine private var isUsingCrossfade = false #endif // MARK: - FFT (radix-2, vDSP_fft_zrip) private var fftSetup: FFTSetup? // Written from audio render thread, read from Timer on main thread. // No lock needed: stale FFT frame is imperceptible at 30fps. nonisolated(unsafe) private var rawFFTLevels: [Float] = Array(repeating: 0, count: 512) // MARK: - Level Simulation (for streams) private var levelTimer: Timer? private var targetLevels: [Float] = Array(repeating: 0, count: 30) private var internalLevels: [Float] = Array(repeating: 0, count: 30) private var publishCounter: Int = 0 private var lastTargetPhase: Int = 0 // MARK: - Offline Visualizer #if os(iOS) private var offlineVisFrames: [[Float]] = [] private var offlineVisTimer: Timer? private var offlineVisFPS: Double = 30.0 #endif // MARK: - Now Playing Artwork private var cachedArtwork: MPMediaItemArtwork? private var cachedArtworkCoverArtId: String? private var cancellables = Set() // MARK: - Init private override init() { // Radix-2 FFT setup for 1024 samples (log2(1024) = 10) fftSetup = vDSP_create_fftsetup(10, Int32(kFFTRadix2)) super.init() configureAudioSession() setupRemoteControls() } // MARK: - Audio Session private func alog(_ msg: String) { #if os(iOS) DebugLogger.shared.log(msg, category: "Audio") #else print("[Audio] \(msg)") #endif } private func configureAudioSession() { #if os(iOS) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default, options: []) } catch { print("Audio session category failed: \(error)") } #elseif os(watchOS) do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default, policy: .longFormAudio) } catch { print("watchOS audio session category failed: \(error)") } #endif } private func activateAudioSession() { do { try AVAudioSession.sharedInstance().setActive(true) } catch { print("Audio session activation failed: \(error)") } } // MARK: - Playback Controls func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0, playlistId: String? = nil, playlistName: String? = nil) { if let q = fromQueue { queue = shuffleEnabled ? q.shuffled() : q queueIndex = index sourcePlaylistId = playlistId sourcePlaylistName = playlistName // Prefetch Smart DJ profiles for upcoming tracks #if os(iOS) if CompanionSettings.shared.smartDJEnabled { let upcoming = Array(queue.dropFirst(index).prefix(10)) Task { await CompanionAPIService().prefetchProfiles(for: upcoming) } } #endif } currentSong = song alog("Playing: \(song.title) by \(song.artist ?? "Unknown")") // Stop radio buffer if switching from radio to music if isRadioStream { isRadioStream = false radioStreamURL = nil #if os(iOS) RadioStreamBuffer.shared.stopBuffering() isPlayingFromBuffer = false #endif } #if os(iOS) // Smart DJ Crossfade path — dual-AVPlayer engine handles everything let crossfade = SmartCrossfadeManager.shared if crossfade.isEnabled && CompanionSettings.shared.smartDJEnabled && !isRadioStream { if let url = crossfade.resolveURL(for: song) { stopAll() isUsingCrossfade = true // Wire callbacks crossfade.timeUpdate = { [weak self] time, dur in self?.currentTime = time self?.duration = dur } crossfade.needsNextTrack = { [weak self] in self?.prepareNextForCrossfade() } crossfade.playSong(song, url: url) isPlaying = true // Set AudioPlayer.player to the active crossfade player // so Lock Screen controls and visualizer still work player = crossfade.activePlayer // Safety: if boundary observer doesn't fire (short track, bad profile), // this catches the natural end and advances normally NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: crossfade.activePlayer.currentItem ) updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: song.coverArt) startLevelSimulation() // Load offline visualizer for crossfade path too if VisualizerSettings.shared.realAudioAnalysis { loadOfflineVisualizer(songId: song.id, url: url) } // Prepare next track in standby prepareNextForCrossfade() return } } isUsingCrossfade = false #endif // Check for offline version first if let localURL = OfflineManager.shared.localURL(for: song.id) { #if os(iOS) if VisualizerSettings.shared.realAudioAnalysis { // Use AVPlayer for reliable playback + offline vis for the visualizer playWithAVPlayer(localURL) loadOfflineVisualizer(songId: song.id, url: localURL) } else { playWithAVPlayer(localURL) } #else playWithAVPlayer(localURL) #endif } else { #if os(iOS) // Check prefetch cache — already downloaded in background if let prefetchURL = AudioPreFetcher.shared.prefetchedURL(for: song.id, suffix: song.suffix) { alog("Playing from prefetch cache") playWithAVPlayer(prefetchURL) if VisualizerSettings.shared.realAudioAnalysis { loadOfflineVisualizer(songId: song.id, url: prefetchURL) } } else { let quality = StreamingQuality.shared let streamURL = ServerManager.shared.client.streamURL( songId: song.id, format: quality.format, maxBitRate: quality.maxBitRate ) if let url = streamURL { playWithAVPlayer(url) } } #else let streamURL = ServerManager.shared.client.streamURL( songId: song.id, format: "mp3", maxBitRate: 192 ) if let url = streamURL { playWithAVPlayer(url) } #endif } // Scrobble Task { try? await ServerManager.shared.client.scrobble(id: song.id, submission: false) } // Pre-fetch next tracks for instant playback #if os(iOS) AudioPreFetcher.shared.prefetchUpcoming(queue: queue, currentIndex: queueIndex) #endif } /// Play a radio stream from a direct URL func playRadio(song: Song, streamURL: URL) { alog("Radio: \(song.title) → \(streamURL.absoluteString)") currentSong = song queue = [song] queueIndex = 0 isRadioStream = true radioStreamURL = streamURL #if os(iOS) // Resolve playlist URLs (.pls, .m3u, .asx) to actual stream URLs, then play // Buffering is NOT started automatically — user taps LIVE to enable it if RadioStreamBuffer.isPlaylistURL(streamURL) { alog("Radio: resolving playlist URL...") Task { let resolved = await RadioStreamBuffer.resolveStreamURL(streamURL) await MainActor.run { self.radioStreamURL = resolved self.playWithAVPlayer(resolved) } } } else { playWithAVPlayer(streamURL) } #else playWithAVPlayer(streamURL) #endif } /// Whether current playback is a radio stream (for buffer/scrub behavior) @Published var isRadioStream = false private(set) var radioStreamURL: URL? #if os(iOS) /// Switch radio playback from live to buffered file for scrubbing private var isPlayingFromBuffer = false func radioSeekBack(to secondsFromStart: TimeInterval) { guard isRadioStream, let bufferURL = RadioStreamBuffer.shared.bufferPlaybackURL else { return } RadioStreamBuffer.shared.isLive = false // Guard: don't seek to exactly 0 — can break partial file reads let seekTime = max(0.1, secondsFromStart) if isPlayingFromBuffer { // Already on buffer — just seek within it let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600) player?.seek(to: cmTime) { [weak self] _ in self?.player?.play() } } else { // First switch: swap AVPlayer to the buffer file isPlayingFromBuffer = true let asset = AVURLAsset(url: bufferURL) let item = AVPlayerItem(asset: asset) player?.replaceCurrentItem(with: item) let cmTime = CMTime(seconds: seekTime, preferredTimescale: 600) player?.seek(to: cmTime) { [weak self] _ in self?.player?.play() } } } /// Jump back to live radio func radioGoLive() { guard isRadioStream, let liveURL = radioStreamURL else { return } RadioStreamBuffer.shared.isLive = true isPlayingFromBuffer = false let asset = AVURLAsset(url: liveURL) let item = AVPlayerItem(asset: asset) player?.replaceCurrentItem(with: item) player?.play() } #endif #if os(iOS) /// Set now playing artwork from a UIImage (for radio station custom covers) func setNowPlayingArtwork(image: UIImage) { let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } cachedArtwork = artwork cachedArtworkCoverArtId = currentSong?.id updateNowPlayingInfo() } #endif // MARK: - AVPlayer Path (streams + fallback) private func playWithAVPlayer(_ url: URL) { alog("AVPlayer path: \(url.scheme ?? "file")://...\(url.lastPathComponent)") stopAll() // Reset audio session to pure playback (fixes radio→music conflict) do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: []) try AVAudioSession.sharedInstance().setActive(true) } catch { print("Audio session reset failed: \(error)") } isUsingRealFFT = false let asset = AVURLAsset(url: url) playerItem = AVPlayerItem(asset: asset) if player == nil { player = AVPlayer(playerItem: playerItem) } else { player?.replaceCurrentItem(with: playerItem) } player?.volume = volume player?.play() isPlaying = true // Time observer let interval = CMTime(seconds: 0.1, preferredTimescale: 600) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self else { return } self.currentTime = time.seconds if let dur = self.playerItem?.duration.seconds, !dur.isNaN { self.duration = dur } } // Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync) nowPlayingSyncTimer?.invalidate() nowPlayingSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in guard let self = self, self.isPlaying else { return } self.updateNowPlayingInfo() } NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem) updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: currentSong?.coverArt) startLevelSimulation() } // MARK: - AVAudioEngine Path (local files with real FFT) private func playLocalWithEngine(_ url: URL) { #if os(iOS) alog("Engine path: \(url.lastPathComponent)") activateAudioSession() stopAll() isUsingRealFFT = true do { let file = try AVAudioFile(forReading: url) engineFile = file let engine = AVAudioEngine() let playerNode = AVAudioPlayerNode() engine.attach(playerNode) engine.connect(playerNode, to: engine.mainMixerNode, format: file.processingFormat) // Install FFT tap on the mixer output let format = engine.mainMixerNode.outputFormat(forBus: 0) engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in self?.processFFT(buffer: buffer) } try engine.start() // Schedule file and play scheduleGeneration += 1 let gen = scheduleGeneration playerNode.scheduleFile(file, at: nil) { [weak self] in DispatchQueue.main.async { guard let self = self, self.scheduleGeneration == gen else { return } self.playerDidFinishEngine() } } playerNode.play() self.audioEngine = engine self.enginePlayerNode = playerNode self.engineStartFrame = 0 isPlaying = true // Duration from file duration = Double(file.length) / file.processingFormat.sampleRate // Time tracking for engine (accounts for seek offset) engineTimeTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in guard let self = self, let node = self.enginePlayerNode, let file = self.engineFile, let nodeTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime: nodeTime) else { return } let sampleRate = file.processingFormat.sampleRate let offsetSeconds = Double(self.engineStartFrame) / sampleRate self.currentTime = offsetSeconds + Double(playerTime.sampleTime) / playerTime.sampleRate } // Publish FFT levels at ~30fps (smoothing already applied in processFFT) levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in guard let self = self else { return } let levels = self.rawFFTLevels self.setLevels(levels) } updateNowPlayingInfo() fetchAndSetArtwork(coverArtId: currentSong?.coverArt) } catch { alog("Engine failed: \(error.localizedDescription), falling back to AVPlayer") playWithAVPlayer(url) } #endif } // MARK: - FFT Processing #if os(iOS) private func processFFT(buffer: AVAudioPCMBuffer) { guard let fftSetup = fftSetup else { return } guard let channelData = buffer.floatChannelData?[0] else { return } let frameCount = buffer.frameLength let fftSize: vDSP_Length = 1024 let log2n: vDSP_Length = 10 guard frameCount >= fftSize else { return } let halfSize = Int(fftSize / 2) // 1. Hann window var windowed = [Float](repeating: 0, count: Int(fftSize)) var window = [Float](repeating: 0, count: Int(fftSize)) vDSP_hann_window(&window, fftSize, Int32(vDSP_HANN_NORM)) vDSP_vmul(channelData, 1, window, 1, &windowed, 1, fftSize) // 2-3. FFT with safe pointers var realp = [Float](repeating: 0, count: halfSize) var imagp = [Float](repeating: 0, count: halfSize) var magnitudes = [Float](repeating: 0, count: halfSize) realp.withUnsafeMutableBufferPointer { realpBuf in imagp.withUnsafeMutableBufferPointer { imagpBuf in var splitComplex = DSPSplitComplex( realp: realpBuf.baseAddress!, imagp: imagpBuf.baseAddress! ) windowed.withUnsafeBytes { rawBuffer in let complexPtr = rawBuffer.bindMemory(to: DSPComplex.self).baseAddress! vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfSize)) } vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(FFT_FORWARD)) vDSP_zvmags(&splitComplex, 1, &magnitudes, 1, vDSP_Length(halfSize)) } } // 4. Normalize by N² and take sqrt for amplitude let fftSizeF = Float(fftSize) var scale: Float = 1.0 / (fftSizeF * fftSizeF) vDSP_vsmul(magnitudes, 1, &scale, &magnitudes, 1, vDSP_Length(halfSize)) // sqrt for perceptual scaling for i in 0.. 3 { seek(to: 0) } else { queueIndex = max(queueIndex - 1, 0) play(song: queue[queueIndex]) } } #if os(iOS) /// Loads the next queue track into the crossfade standby player. /// If no next track exists, SmartCrossfadeManager will self-crossfade. private func prepareNextForCrossfade() { let crossfade = SmartCrossfadeManager.shared guard crossfade.isEnabled, !queue.isEmpty else { return } // Always wire the callback first crossfade.needsNextTrack = { [weak self] in guard let self = self else { return } // Check if there's actually a next track to advance to let hasNext: Bool if self.repeatMode == .all { hasNext = true } else { hasNext = self.queueIndex + 1 < self.queue.count } if hasNext { // Advance queue if self.repeatMode == .all { self.queueIndex = (self.queueIndex + 1) % self.queue.count } else { self.queueIndex = min(self.queueIndex + 1, self.queue.count - 1) } self.currentSong = self.queue[self.queueIndex] } // else: self-crossfade — keep same song, don't advance self.player = crossfade.activePlayer self.updateNowPlayingInfo() self.fetchAndSetArtwork(coverArtId: self.currentSong?.coverArt) // Prepare the NEXT next track (or self-crossfade again) self.prepareNextForCrossfade() } // Now check if there's actually a next track to prepare let nextIdx: Int if repeatMode == .all { nextIdx = (queueIndex + 1) % queue.count } else { nextIdx = queueIndex + 1 guard nextIdx < queue.count else { // No next track — SmartCrossfadeManager will self-crossfade alog("SmartDJ: no next track — will self-crossfade") return } } let nextSong = queue[nextIdx] guard let url = crossfade.resolveURL(for: nextSong) else { return } alog("SmartDJ: preparing \(nextSong.title) in standby") crossfade.prepareNext(nextSong, url: url) } #endif func seek(to time: TimeInterval) { #if os(iOS) if isUsingCrossfade { SmartCrossfadeManager.shared.seek(to: time) currentTime = time updateNowPlayingInfo() return } #endif let wasPaused = !isPlaying if let node = enginePlayerNode, let file = engineFile, let engine = audioEngine { let sampleRate = file.processingFormat.sampleRate let startFrame = AVAudioFramePosition(time * sampleRate) let totalFrames = file.length guard startFrame >= 0, startFrame < totalFrames else { return } let remainingFrames = AVAudioFrameCount(totalFrames - startFrame) scheduleGeneration += 1 let gen = scheduleGeneration node.stop() node.scheduleSegment(file, startingFrame: startFrame, frameCount: remainingFrames, at: nil) { [weak self] in DispatchQueue.main.async { guard let self = self, self.scheduleGeneration == gen else { return } self.playerDidFinishEngine() } } if engine.isRunning { node.play() // If we were paused, immediately pause again (we only played to set position) if wasPaused { node.pause() } } currentTime = time engineStartFrame = startFrame updateNowPlayingInfo() } else { currentTime = time // Update immediately for responsive UI let cmTime = CMTime(seconds: time, preferredTimescale: 600) player?.seek(to: cmTime) { [weak self] finished in guard let self = self, finished else { return } if wasPaused { self.player?.pause() } self.updateNowPlayingInfo() } } } func seekToPercent(_ pct: Double) { seek(to: duration * pct) } func toggleShuffle() { shuffleEnabled.toggle() } func cycleRepeat() { switch repeatMode { case .off: repeatMode = .all case .all: repeatMode = .one case .one: repeatMode = .off } } // MARK: - Queue Management func playNext(_ song: Song) { let insertAt = queueIndex + 1 if insertAt <= queue.count { queue.insert(song, at: insertAt) } else { queue.append(song) } } func playLater(_ song: Song) { queue.append(song) } func playNow(_ song: Song) { queue.insert(song, at: queueIndex + 1) queueIndex += 1 play(song: song) } func playInstantMix(basedOn song: Song) { Task { let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50) if let songs = similar, !songs.isEmpty { await MainActor.run { self.play(song: songs[0], fromQueue: songs) } } } } @objc private func playerDidFinish() { if let song = currentSong { Task { try? await ServerManager.shared.client.scrobble(id: song.id) } } if repeatMode == .one { seek(to: 0) player?.play() } else { next() } } // MARK: - Now Playing Info Center private func updateNowPlayingInfo() { #if os(iOS) var info: [String: Any] = [:] info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown" info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown" info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? "" info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime info[MPMediaItemPropertyPlaybackDuration] = duration info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 if let artwork = cachedArtwork { info[MPMediaItemPropertyArtwork] = artwork } MPNowPlayingInfoCenter.default().nowPlayingInfo = info #endif } private func fetchAndSetArtwork(coverArtId: String?) { #if os(iOS) guard let id = coverArtId else { return } if id == cachedArtworkCoverArtId, cachedArtwork != nil { return } // Check user-set custom cover first if let customImage = AlbumCoverStore.shared.loadCover(for: id) { let artwork = MPMediaItemArtwork(boundsSize: customImage.size) { _ in customImage } self.cachedArtwork = artwork self.cachedArtworkCoverArtId = id self.updateNowPlayingInfo() return } guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 600) else { return } if let cached = ImageCache.shared.cachedImage(for: url) { let artwork = MPMediaItemArtwork(boundsSize: cached.size) { _ in cached } self.cachedArtwork = artwork self.cachedArtworkCoverArtId = id self.updateNowPlayingInfo() return } Task { do { let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { return } ImageCache.shared.store(image, for: url) let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } await MainActor.run { self.cachedArtwork = artwork self.cachedArtworkCoverArtId = id self.updateNowPlayingInfo() } } catch { } } #endif } private func setupRemoteControls() { #if os(iOS) || os(watchOS) let center = MPRemoteCommandCenter.shared() center.playCommand.addTarget { [weak self] _ in self?.resume(); return .success } center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } center.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success } center.previousTrackCommand.addTarget { [weak self] _ in self?.previous() return .success } center.changePlaybackPositionCommand.addTarget { [weak self] event in guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } self?.seek(to: posEvent.positionTime) return .success } #endif } // MARK: - Offline Visualizer Pipeline #if os(iOS) /// Load or generate cached visualizer data for a downloaded song private func loadOfflineVisualizer(songId: String, url: URL) { stopOfflineVisTimer() isUsingOfflineVis = false offlineVisProgress = 0 offlineVisFPS = VisualizerSettings.shared.effectiveFPS let points = VisualizerSettings.shared.numberOfPoints let cutoff = VisualizerSettings.shared.frequencyCutoff Task { let storage = VisualizerStorageManager.shared let hasCache = await storage.hasCache(for: songId) if hasCache { alog("Offline vis: loading cache for \(songId)") if let frames = try? await storage.loadCache(for: songId) { let normalized = Self.normalizeVisFrames(frames) await MainActor.run { self.offlineVisFrames = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 self.startOfflineVisSync() } } } else { // Try Companion API first (pre-computed server frames) var fetched = false if CompanionSettings.shared.isEnabled, let path = self.currentSong?.path { do { if let serverFrames = try await CompanionAPIService().fetchVisualizerFrames(relativePath: path) { let normalized = Self.normalizeVisFrames(serverFrames) try? await storage.saveCache(frames: serverFrames, for: songId) alog("Offline vis: fetched \(serverFrames.count) frames from server") await MainActor.run { self.offlineVisFrames = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 self.startOfflineVisSync() } fetched = true } } catch { alog("Offline vis: server fetch failed, will analyze locally") } } if !fetched { alog("Offline vis: analyzing \(songId)...") do { let frames = try await OfflineAudioAnalyzer.shared.analyze( url: url, pointsCount: points, fps: offlineVisFPS, cutoff: cutoff ) { [weak self] pct in Task { @MainActor in self?.offlineVisProgress = pct } } try? await storage.saveCache(frames: frames, for: songId) let normalized = Self.normalizeVisFrames(frames) alog("Offline vis: cached \(frames.count) frames for \(songId)") await MainActor.run { self.offlineVisFrames = normalized self.isUsingOfflineVis = true self.isUsingRealFFT = true self.offlineVisProgress = 1.0 self.startOfflineVisSync() } } catch { alog("Offline vis: analysis failed: \(error.localizedDescription)") await MainActor.run { self.startLevelSimulation() } } } // end if !fetched } } } /// Normalize vis frames so the wave is always visible regardless of song loudness. /// Maps the 95th percentile peak to 0.8, preserving dynamics without clipping. private static func normalizeVisFrames(_ frames: [[Float]]) -> [[Float]] { guard !frames.isEmpty else { return frames } // Collect all non-zero values to find the peak var allValues: [Float] = [] allValues.reserveCapacity(frames.count * (frames.first?.count ?? 20)) for frame in frames { for v in frame where v > 0.001 { allValues.append(v) } } guard !allValues.isEmpty else { return frames } // Use 95th percentile instead of absolute max to avoid outlier spikes allValues.sort() let p95Index = min(Int(Float(allValues.count) * 0.95), allValues.count - 1) let p95 = allValues[p95Index] guard p95 > 0.001 else { return frames } let scale = 0.8 / p95 return frames.map { frame in frame.map { min(1.0, $0 * scale) } } } /// Sync cached frames to current playback time. /// Uses proportional mapping (time/duration → frame index) so it works /// regardless of what FPS the frames were analyzed at. private func startOfflineVisSync() { stopOfflineVisTimer() stopLevelTimer() let frameCount = offlineVisFrames.count alog("Offline vis sync: \(frameCount) frames, duration=\(String(format: "%.1f", duration))s") // Wait briefly for duration to become available if it's still 0 if duration <= 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.startOfflineVisSyncTimer() } } else { startOfflineVisSyncTimer() } } private func startOfflineVisSyncTimer() { stopOfflineVisTimer() offlineVisTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in guard let self = self, self.isUsingOfflineVis, self.isPlaying else { return } let frames = self.offlineVisFrames guard !frames.isEmpty else { return } let dur = self.duration let time = self.currentTime // Map playback position to frame index proportionally let frameIndex: Int if dur > 0 { let pct = min(time / dur, 1.0) frameIndex = min(Int(pct * Double(frames.count)), frames.count - 1) } else { // Duration still unknown — estimate at 30fps frameIndex = min(Int(time * 30.0), frames.count - 1) } if frameIndex >= 0, frameIndex < frames.count { self.setLevels(frames[frameIndex]) } } } private func stopOfflineVisTimer() { offlineVisTimer?.invalidate() offlineVisTimer = nil } #endif // MARK: - Simulated Level Animation (for streams) private func startLevelSimulation() { stopLevelTimer() levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in guard let self = self, self.isPlaying else { if self?.internalLevels.contains(where: { $0 > 0.01 }) == true { self?.internalLevels = Array(repeating: 0, count: 30) self?.setLevels(self?.internalLevels ?? []) } return } let time = self.currentTime let phase = Int(time * 12) if phase != self.lastTargetPhase { self.lastTargetPhase = phase self.targetLevels = (0..<30).map { _ in Float.random(in: 0.1...0.85) } } let smoothing: Float = 0.15 self.internalLevels = zip(self.internalLevels, self.targetLevels).map { current, target in current + (target - current) * smoothing } self.publishCounter += 1 if self.publishCounter % 2 == 0 { self.setLevels(self.internalLevels) } } } // MARK: - Cleanup private func stopLevelTimer() { levelTimer?.invalidate() levelTimer = nil } private func stopEngineTimer() { engineTimeTimer?.invalidate() engineTimeTimer = nil } private func stopEngine() { scheduleGeneration += 1 // invalidate all pending completions enginePlayerNode?.stop() audioEngine?.mainMixerNode.removeTap(onBus: 0) audioEngine?.stop() audioEngine = nil enginePlayerNode = nil engineFile = nil stopEngineTimer() } private func stopAVPlayer() { player?.pause() player?.replaceCurrentItem(with: nil) if let observer = timeObserver { player?.removeTimeObserver(observer) timeObserver = nil } playerItem = nil } /// Stop everything — called before starting new playback private func stopAll() { stopLevelTimer() nowPlayingSyncTimer?.invalidate() nowPlayingSyncTimer = nil #if os(iOS) stopOfflineVisTimer() if isUsingCrossfade { SmartCrossfadeManager.shared.stop() isUsingCrossfade = false } #endif stopEngine() stopAVPlayer() NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) } func stop() { stopAll() isPlaying = false currentSong = nil setLevels(Array(repeating: 0, count: 30)) internalLevels = Array(repeating: 0, count: 30) rawFFTLevels = Array(repeating: 0, count: 512) isUsingRealFFT = false isUsingOfflineVis = false #if os(iOS) offlineVisFrames = [] #endif if isRadioStream { isRadioStream = false radioStreamURL = nil #if os(iOS) RadioStreamBuffer.shared.stopBuffering() isPlayingFromBuffer = false #endif } } deinit { if let setup = fftSetup { vDSP_destroy_fftsetup(setup) } stop() } }