From bae97dc4a98477276fe77851e427ce67add36fae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 01:38:42 +0000 Subject: [PATCH] Cut per-tick allocation and SwiftData churn on the 20 Hz playback path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit findings for audio glitches under high CPU load — all behavior- identical: - The 20 Hz timer wrapped every tick() in a fresh Task even though it already fires on the main run loop: ~1200 heap allocations + actor-hop requeues per minute, each deferring the tick to a later scheduling slot. Run it synchronously via MainActor.assumeIsolated (same pattern as the audio-session handlers). - effectiveEndSeconds read track.audibleEndSeconds (a SwiftData managed read) 20x/s from tick + UI; now served from a ~1 Hz-refreshed cache (the value only moves when the silence scan backfills, minutes before it matters). - The ~5 s periodic persist re-mapped queue.map(\.id) (one SwiftData read per queued track) and built a fresh JSONEncoder every call; the ID array is now invalidated only on queue mutation and the coders are static. - Lock-screen artwork fetch drops to utility priority. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013TJoWkqg8bzkGdzxWhWjWP --- .../Sources/Playback/NowPlayingBridge.swift | 4 ++- .../Sources/Playback/PlaybackStateStore.swift | 9 ++++-- .../Sources/Playback/Player+History.swift | 6 +++- .../Sources/Playback/Player.swift | 30 +++++++++++++++---- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift b/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift index 57dee46..192b9a0 100644 --- a/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift +++ b/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift @@ -89,7 +89,9 @@ final class NowPlayingBridge { artworkURL = track.artworkURL guard let url = track.artworkURL else { return } - artworkTask = Task { [weak self] in + // Lock-screen art is eventually-visible, not latency-critical — utility priority keeps + // the fetch from competing with blend/tick work under load. + artworkTask = Task(priority: .utility) { [weak self] in guard let (data, _) = try? await URLSession.shared.data(from: url), let image = UIImage(data: data), !Task.isCancelled else { return } let art = MPMediaItemArtwork(boundsSize: image.size) { _ in image } diff --git a/Packages/ContinuityKit/Sources/Playback/PlaybackStateStore.swift b/Packages/ContinuityKit/Sources/Playback/PlaybackStateStore.swift index 807451d..20d41d2 100644 --- a/Packages/ContinuityKit/Sources/Playback/PlaybackStateStore.swift +++ b/Packages/ContinuityKit/Sources/Playback/PlaybackStateStore.swift @@ -16,13 +16,18 @@ public struct PersistedPlaybackState: Codable, Equatable { public enum PlaybackStateStore { private static let key = "playbackState.v1" + // Reused across the ~5 s periodic saves — constructing a fresh coder per call is pure + // allocation churn on the main thread. + private static let encoder = JSONEncoder() + private static let decoder = JSONDecoder() + public static func load() -> PersistedPlaybackState? { guard let data = UserDefaults.standard.data(forKey: key) else { return nil } - return try? JSONDecoder().decode(PersistedPlaybackState.self, from: data) + return try? decoder.decode(PersistedPlaybackState.self, from: data) } static func save(_ state: PersistedPlaybackState) { - guard let data = try? JSONEncoder().encode(state) else { return } + guard let data = try? encoder.encode(state) else { return } UserDefaults.standard.set(data, forKey: key) } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+History.swift b/Packages/ContinuityKit/Sources/Playback/Player+History.swift index 76798b0..77c0905 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+History.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+History.swift @@ -24,8 +24,12 @@ extension Player { skipsRemaining: skipsRemaining ) guard !queue.isEmpty else { return } + // queue.map(\.id) reads a SwiftData property per queued track — wasteful for the ~5 s + // periodic save where the queue hasn't changed. The cache invalidates on queue mutation + // (didSet in Player.swift), so the periodic save reuses the same array. + if cachedQueueIDs == nil { cachedQueueIDs = queue.map(\.id) } PlaybackStateStore.save(PersistedPlaybackState( - queueTrackIDs: queue.map(\.id), + queueTrackIDs: cachedQueueIDs ?? queue.map(\.id), currentIndex: currentIndex, positionSeconds: position, skipsRemaining: skipsRemaining, diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index 6bc4c7d..2f9ae45 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -45,7 +45,15 @@ final class AudioStack { @Observable public final class Player { // MARK: Observable state - var queue: [Track] = [] + var queue: [Track] = [] { + didSet { cachedQueueIDs = nil } // persistState() rebuilds the ID array lazily + } + /// Cached `queue.map(\.id)` for the periodic persist — each `.id` is a SwiftData read, + /// so re-mapping an unchanged queue every ~5 s was pure managed-object churn. + @ObservationIgnored var cachedQueueIDs: [UUID]? + /// Cached `audibleEndSeconds` of the loaded track (refreshed ~1 Hz in `tick()`), so the + /// 20 Hz `effectiveEndSeconds` reads don't hit SwiftData property machinery every tick. + @ObservationIgnored var trimCache: (track: Track, end: Double?)? var currentIndex = 0 public internal(set) var isPlaying = false public internal(set) var isTransitioning = false @@ -306,8 +314,12 @@ public final class Player { func startTimer() { stopTimer() // 20 Hz: smooth enough for the volume ramp and the scrubber. + // The timer fires on RunLoop.main, i.e. already on the main actor — run tick() + // synchronously (same pattern as the audio-session notification handlers). Wrapping + // it in Task {} allocated and re-queued 20 tasks/second, deferring each tick to a + // later scheduling slot — measurable churn exactly when the device is under load. let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in - Task { @MainActor in self?.tick() } + MainActor.assumeIsolated { self?.tick() } } RunLoop.main.add(timer, forMode: .common) displayTimer = timer @@ -327,9 +339,11 @@ public final class Player { let full = duration // Pre-audio the staged track carries the same trim metadata as the loaded deck would. let track = audio?.current.track ?? currentTrack - guard transitionSettings.trimSilenceEnabled, - let audibleEnd = track?.audibleEndSeconds, - audibleEnd > 1, audibleEnd < full else { return full } + guard transitionSettings.trimSilenceEnabled, let track else { return full } + // Hot path (20 Hz from tick + UI): use the ~1 Hz cache instead of a SwiftData read. + // Cache misses (pre-audio, track just changed) fall through to the direct read. + let end = trimCache?.track === track ? trimCache?.end : track.audibleEndSeconds + guard let audibleEnd = end, audibleEnd > 1, audibleEnd < full else { return full } return audibleEnd } @@ -362,6 +376,12 @@ public final class Player { ticksSincePersist = 0 persistState() } + // Refresh the trim cache on track change and ~1 Hz: audibleEndSeconds only ever moves + // when the silence scan backfills it (minutes before it matters at track end), so a + // ≤1 s pickup delay is imperceptible — and it keeps SwiftData reads off the 20 Hz path. + if let track = audio.current.track, trimCache?.track !== track || memTicks % 20 == 0 { + trimCache = (track, track.audibleEndSeconds) + } let dur = effectiveEndSeconds if isTransitioning { let plan = TransitionPlan(