diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 0422f14..18be65f 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -89,6 +89,7 @@ class AudioPlayer: NSObject, ObservableObject { private var offlineVisBuffer = VisFrameBuffer.empty private var offlineVisTimer: Timer? private var offlineVisFPS: Double = 30.0 + private var analysisTask: Task? #endif // MARK: - Now Playing Artwork @@ -133,11 +134,14 @@ class AudioPlayer: NSObject, ObservableObject { private func suspendVisTimers() { stopOfflineVisTimer() stopLevelTimer() - // Remove the periodic time observer — it fires at 10Hz updating @Published currentTime, - // which triggers SwiftUI re-evaluation of the entire view tree in background. - // This is the primary driver of the 93% background CPU that kills the process. + // Cancel any in-progress offline vis analysis — it's a CPU-intensive FFT + // loop that will otherwise run to completion in background, burning ~60% CPU + analysisTask?.cancel() + analysisTask = nil + // Remove the periodic time observer — it fires at 10Hz updating @Published + // currentTime, driving SwiftUI re-evaluation of the entire view tree in background removeTimeObserver() - alog("Background: timers + time observer suspended") + alog("Background: timers + time observer + analysis task suspended") } private func resumeVisTimers() { @@ -1231,7 +1235,8 @@ class AudioPlayer: NSObject, ObservableObject { let points = VisualizerSettings.shared.nowPlaying.numberOfPoints let cutoff = VisualizerSettings.shared.frequencyCutoff - Task { + analysisTask?.cancel() // cancel any in-flight analysis from the previous song + analysisTask = Task { let storage = VisualizerStorageManager.shared let hasCache = await storage.hasCache(for: songId) @@ -1504,6 +1509,8 @@ class AudioPlayer: NSObject, ObservableObject { nowPlayingSyncTimer = nil #if os(iOS) stopOfflineVisTimer() + analysisTask?.cancel() + analysisTask = nil if isUsingCrossfade { SmartCrossfadeManager.shared.stop() isUsingCrossfade = false diff --git a/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift b/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift index 4d6e0af..b8654c8 100644 --- a/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift +++ b/iOS/Views/Visualizer/OfflineAudioAnalyzer.swift @@ -104,6 +104,9 @@ actor OfflineAudioAnalyzer { var nextFrameSample = 0 // the audio sample index at which to take the next vis frame while file.framePosition < totalFrames { + // Cooperatively cancel if the app backgrounded mid-analysis + try Task.checkCancellation() + let toRead = min(AVAudioFrameCount(readChunkSamples), AVAudioFrameCount(totalFrames - file.framePosition)) readBuffer.frameLength = 0 @@ -138,6 +141,8 @@ actor OfflineAudioAnalyzer { let chunkEnd = chunkStart + chunkLen while nextFrameSample < chunkEnd { + // Check cancellation every frame — the inner loop is the hot path + try Task.checkCancellation() // We need fftSize samples ending at nextFrameSample + fftSize/2 // (centre the FFT window on the frame position for better transient response) let windowStart = nextFrameSample - fftSize / 2