From 3bb3a124bb8be367eb537aeedee3a4f007790b50 Mon Sep 17 00:00:00 2001 From: sanylax Date: Thu, 10 Sep 2026 23:08:57 +0000 Subject: [PATCH 1/7] Cut the main-thread work out of the UI, data and launch paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Now Playing transition panel was rebuilt 20 times a second: it read `secondsUntilTransition`, which derives from the tick-driven `position`, so every tick re-ran `TransitionPreview.make`, re-read both tracks' `beatTimes` out of SwiftData and redrew two Canvases. The countdown now mirrors at 1 Hz (`Player.transitionCountdownSeconds`) and the two moving parts — countdown and blend playhead — are leaves, so the panel itself only rebuilds on track changes. Beat positions and curve samples resolve once in `init`. Also on the hot paths: - `AudioCache`/`StemCache`/`ArtworkStore.directory` were computed properties running `createDirectory` + `setResourceValues` on every access — i.e. two syscalls per artwork URL, per row, per frame. Memoized as `static let`. - `Playlist.artworkURL` sorted and copied the whole track relationship on every playlist card body; now a single pass. - Playlist detail and library search rows took the now-playing highlight as a parameter, so each track change invalidated the parent and re-sorted the whole list. They read the player themselves now. Library search also filters before ordering instead of sorting every playlist per keystroke. - Artwork now goes through one shared bounded decode cache (`ArtworkImageStore`) with ImageIO downsampling, replacing `AsyncImage`, which caches nothing and re-decoded full-size JPEGs per row. Backdrop renders are coalesced per URL (the pager asked for the same artwork twice per track change), bounded in an NSCache instead of an unbounded dictionary, and share one `CIContext`. - Launch: the resume pass did up to five `fileExists` probes per track; it now takes one directory listing per cache. Library cleanup's enumeration and deletes moved off the main actor, and playlist deletes no longer re-list the audio cache once per removed track. Session restore and queue refill fetch ids only rather than hydrating every track's analysis arrays. - Stem-cache budget passes coalesce instead of stacking one full directory scan per skip. Liquid Glass: every `Material` stand-in is now a real `glassEffect` — the skip badge, transition countdown and chips, DEMO badge, search pill, error card, suggestion chips and the in-app keyboard's keys, with the key grid and the chip row in `GlassEffectContainer`s so each group composites in one pass. The keyboard's per-keystroke `UIImpactFeedbackGenerator` (never prepared, and a new object reference that invalidated all ~40 keys) is now shared and pre-armed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- App/Continuity/Views/ContinuityApp.swift | 20 ++ App/Continuity/Views/LibraryView.swift | 82 +++--- App/Continuity/Views/NowPlayingView.swift | 23 +- App/Continuity/Views/PlaylistDetailView.swift | 25 +- App/Continuity/Views/RootView.swift | 17 +- App/Continuity/Views/SearchView.swift | 88 ++++-- App/Continuity/Views/Theme.swift | 264 +++++++++++++++--- .../Views/TransitionVisualizationView.swift | 191 ++++++++----- .../Sources/Domain/AudioCache.swift | 10 +- .../Sources/Domain/Playlist.swift | 15 +- .../Sources/Domain/StemCache.swift | 8 +- .../ContinuityKit/Sources/Domain/Track.swift | 8 +- .../Sources/Ingest/LibraryCleanup.swift | 92 +++--- .../Ingest/PreparationQueue+Stems.swift | 89 +++++- .../Sources/Ingest/PreparationQueue.swift | 27 +- .../Sources/Playback/Player+History.swift | 3 + .../Sources/Playback/Player+Queue.swift | 10 + .../Sources/Playback/Player.swift | 22 ++ 18 files changed, 743 insertions(+), 251 deletions(-) diff --git a/App/Continuity/Views/ContinuityApp.swift b/App/Continuity/Views/ContinuityApp.swift index 753af4c..689b454 100644 --- a/App/Continuity/Views/ContinuityApp.swift +++ b/App/Continuity/Views/ContinuityApp.swift @@ -12,6 +12,7 @@ struct ContinuityApp: App { @State private var prepQueue = PreparationQueue() init() { + Self.configureURLCache() do { let schema = Schema([Playlist.self, Track.self, TransitionVote.self]) // Pin the store to the app container. `groupContainer` defaults to `.automatic`, @@ -41,9 +42,28 @@ struct ContinuityApp: App { .modelContainer(container) } + /// The app is artwork-heavy: every library row, the mini player, Now Playing and the + /// backdrop all pull covers over the network. The default shared cache is small enough that + /// scrolling a library re-downloads thumbnails it fetched seconds earlier, so size it for + /// the working set. (Decoded images are cached separately and bounded by + /// `ArtworkImageStore`; this is the encoded-bytes tier underneath it.) + private static func configureURLCache() { + URLCache.shared = URLCache( + memoryCapacity: 16 * 1024 * 1024, + diskCapacity: 256 * 1024 * 1024 + ) + } + /// One-time move of an existing app-group SwiftData store into Application Support so /// libraries built before `groupContainer: .none` aren't orphaned beside an empty new store. + /// + /// Latched in defaults once it has run: resolving the app-group container URL and probing + /// for the legacy store is filesystem work on every cold launch, forever, to answer a + /// question that can only change once. private static func migrateStoreOutOfAppGroupIfNeeded() { + let migrationKey = "didMigrateStoreOutOfAppGroup.v1" + if UserDefaults.standard.bool(forKey: migrationKey) { return } + defer { UserDefaults.standard.set(true, forKey: migrationKey) } let fm = FileManager.default guard let groupRoot = fm.containerURL( forSecurityApplicationGroupIdentifier: "group.com.sanylax.continuity" diff --git a/App/Continuity/Views/LibraryView.swift b/App/Continuity/Views/LibraryView.swift index 5101f32..db27072 100644 --- a/App/Continuity/Views/LibraryView.swift +++ b/App/Continuity/Views/LibraryView.swift @@ -108,12 +108,20 @@ private struct SearchResultsView: View { } /// Track matches by title or artist, capped so pathological queries stay snappy. + /// + /// Filters first and orders only the matches: `orderedTracks` sorts and copies every track + /// in a playlist, and this whole scan re-runs on each keystroke, so the old shape paid a + /// full library sort per character typed. private var matchingTracks: [Track] { var results: [Track] = [] for playlist in playlists { - for track in playlist.orderedTracks - where track.title.localizedCaseInsensitiveContains(query) - || track.artist.localizedCaseInsensitiveContains(query) { + var matches = playlist.tracks.filter { + $0.title.localizedCaseInsensitiveContains(query) + || $0.artist.localizedCaseInsensitiveContains(query) + } + if matches.isEmpty { continue } + matches.sort { $0.sortIndex < $1.sortIndex } + for track in matches { results.append(track) if results.count >= 100 { return results } } @@ -148,34 +156,7 @@ private struct SearchResultsView: View { if !trackMatches.isEmpty { Section("Songs") { ForEach(trackMatches) { track in - Button { - play(track) - } label: { - HStack(spacing: 12) { - RemoteArtworkView(url: track.artworkURL, symbol: track.artworkSymbol, - seed: track.gradientSeed, cornerRadius: 8) - .frame(width: 44, height: 44) - VStack(alignment: .leading, spacing: 2) { - Text(track.title) - .foregroundStyle(player.currentTrack?.id == track.id - ? AnyShapeStyle(.tint) : AnyShapeStyle(.primary)) - .lineLimit(1) - Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Spacer() - Text(Theme.time(track.durationSeconds)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .buttonStyle(.plain) - .contextMenu { - Button { - player.playNext(track) - } label: { - Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") - } - } + SearchSongRow(track: track) { play(track) } } } } @@ -197,6 +178,43 @@ private struct SearchResultsView: View { } } +/// One song result. Reads the now-playing highlight itself, so a track change re-renders the +/// affected rows instead of re-running the whole library scan in `SearchResultsView.body`. +private struct SearchSongRow: View { + let track: Track + let play: () -> Void + @Environment(Player.self) private var player + + var body: some View { + Button(action: play) { + HStack(spacing: 12) { + RemoteArtworkView(url: track.artworkURL, symbol: track.artworkSymbol, + seed: track.gradientSeed, cornerRadius: 8) + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 2) { + Text(track.title) + .foregroundStyle(player.currentTrack?.id == track.id + ? AnyShapeStyle(.tint) : AnyShapeStyle(.primary)) + .lineLimit(1) + Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Text(Theme.time(track.durationSeconds)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .contextMenu { + Button { + player.playNext(track) + } label: { + Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") + } + } + } +} + private struct PlaylistCard: View { let playlist: Playlist @@ -211,7 +229,7 @@ private struct PlaylistCard: View { .font(.system(size: 10, weight: .bold)) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) + .continuityGlassCapsule() .padding(8) } } diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index c182941..9ce81ee 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -205,16 +205,15 @@ struct NowPlayingView: View { .buttonStyle(.plain) } - /// Remaining forward skips, as a subtle glass pill under Next. + /// Remaining forward skips, as a subtle glass pill under Next. Real Liquid Glass — the + /// material + hand-drawn hairline it used to fake carries its own edge highlight. private var skipBadge: some View { Text("\(player.skipsRemaining)") .font(.caption.weight(.bold).monospacedDigit()) .foregroundStyle(.white.opacity(0.9)) .padding(.vertical, 3) .padding(.horizontal, 9) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.15))) - .shadow(color: .black.opacity(0.3), radius: 6, y: 2) + .continuityGlassCapsule() .accessibilityLabel("\(player.skipsRemaining) skips remaining") } @@ -329,7 +328,11 @@ private struct ScrubberBar: View { } } -/// Leaf: the screen's only reader of the 20 Hz-derived blend state. +/// Chooses which pair the transition panel describes. Deliberately reads only state that +/// changes at track/blend boundaries — never `position`, `transitionProgress` or +/// `secondsUntilTransition`. The two moving parts inside the panel are their own leaves, so +/// this body (and the `TransitionPreview` + beat-grid work in the panel's `init`) runs on +/// track changes, not twenty times a second. private struct TransitionSection: View { @Environment(Player.self) private var player @@ -339,19 +342,15 @@ private struct TransitionSection: View { settings: player.transitionSettings, outgoing: current, incoming: next, - isLive: true, - liveProgress: min(max(player.transitionProgress, 0), 1), - secondsUntil: nil + isLive: true ) .transition(.opacity) - } else if let current = player.currentTrack, let next = player.upcomingTracks.first { + } else if let current = player.currentTrack, let next = player.nextUpcomingTrack { TransitionVisualizationView( settings: player.transitionSettings, outgoing: current, incoming: next, - isLive: false, - liveProgress: 0, - secondsUntil: player.secondsUntilTransition + isLive: false ) .transition(.opacity) } diff --git a/App/Continuity/Views/PlaylistDetailView.swift b/App/Continuity/Views/PlaylistDetailView.swift index 4242b39..f6b396d 100644 --- a/App/Continuity/Views/PlaylistDetailView.swift +++ b/App/Continuity/Views/PlaylistDetailView.swift @@ -14,23 +14,27 @@ struct PlaylistDetailView: View { private var isSyncing: Bool { prepQueue.syncingPlaylistIDs.contains(playlist.id) } var body: some View { - List { + // Resolved once per body evaluation: `orderedTracks` sorts and copies the whole + // relationship array, and this body used to call it three times (rows, tap handler, + // Play button) — plus once more per tap. + let tracks = playlist.orderedTracks + return List { // The header is a regular row — NOT a pinned section header, which in a plain list // floats transparently over the rows and lets them scroll underneath the Play button. // As a row it scrolls away with the content, Apple Music-style. - header + header(tracks) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets()) - ForEach(Array(playlist.orderedTracks.enumerated()), id: \.element.id) { index, track in - TrackRow(track: track, isCurrent: player.currentTrack?.id == track.id) + ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in + TrackRow(track: track) .contentShape(Rectangle()) .onTapGesture { // A failed ingest can't be played — tapping it retries instead. if track.prepState == .failed { prepQueue.enqueue(track, in: modelContext) } else { - player.play(tracks: playlist.orderedTracks, startAt: index) + player.play(tracks: tracks, startAt: index) pagerState.goToNowPlaying() } } @@ -69,7 +73,7 @@ struct PlaylistDetailView: View { LibraryCleanup.removeOrphanedFiles(keys: [key], in: modelContext) } - private var header: some View { + private func header(_ tracks: [Track]) -> some View { VStack(spacing: 12) { RemoteArtworkView(url: playlist.artworkURL, symbol: playlist.artworkSymbol, seed: playlist.gradientSeed, cornerRadius: 20) .frame(width: 180, height: 180) @@ -77,7 +81,7 @@ struct PlaylistDetailView: View { Text(playlist.title).font(.title2.bold()) Text(playlist.subtitle).font(.subheadline).foregroundStyle(.secondary) Button { - player.play(tracks: playlist.orderedTracks, startAt: 0) + player.play(tracks: tracks, startAt: 0) pagerState.goToNowPlaying() } label: { Label("Play", systemImage: "play.fill") @@ -118,9 +122,14 @@ struct PlaylistDetailView: View { } } +/// One track row. Reads the now-playing highlight itself rather than taking it as a parameter: +/// as a parameter, every track change invalidated `PlaylistDetailView.body` — which re-sorted +/// the entire playlist to rebuild the list. private struct TrackRow: View { let track: Track - let isCurrent: Bool + @Environment(Player.self) private var player + + private var isCurrent: Bool { player.currentTrack?.id == track.id } var body: some View { HStack(spacing: 12) { diff --git a/App/Continuity/Views/RootView.swift b/App/Continuity/Views/RootView.swift index 81ce29e..7e27c6d 100644 --- a/App/Continuity/Views/RootView.swift +++ b/App/Continuity/Views/RootView.swift @@ -88,7 +88,12 @@ struct RootView: View { // Natural queue exhaustion loops playback into the listening history — resolve // the persisted IDs to live tracks in order; deleted tracks simply drop out. player.onQueueExhausted = { ids in - let tracks = (try? modelContext.fetch(FetchDescriptor())) ?? [] + // Only the id → Track mapping is needed; a bare fetch hydrates every row's + // `beatTimes` array (hundreds of doubles each) to build a dictionary of + // UUIDs. The rest of each surviving track faults in when it's played. + var descriptor = FetchDescriptor() + descriptor.propertiesToFetch = [\.id] + let tracks = (try? modelContext.fetch(descriptor)) ?? [] let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) return ids.compactMap { byID[$0] } } @@ -236,10 +241,15 @@ struct RootView: View { /// fresh install, stages COMË N GO paused at the start of its playlist. private func restorePlaybackSession() { guard player.currentTrack == nil else { return } // already playing (e.g. state restore re-entry) - let tracks = (try? modelContext.fetch(FetchDescriptor())) ?? [] if let state = PlaybackStateStore.load() { - let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) + // The common launch path only resolves ids to rows. Fetching whole tracks here + // hydrated every one of them — `beatTimes` arrays included — on the launch path, + // to build a dictionary of UUIDs. + var descriptor = FetchDescriptor() + descriptor.propertiesToFetch = [\.id] + let stored = (try? modelContext.fetch(descriptor)) ?? [] + let byID = Dictionary(uniqueKeysWithValues: stored.map { ($0.id, $0) }) player.restore(state, resolving: byID) if player.currentTrack != nil { return } // Every persisted track was deleted — fall through to the first-run seed. @@ -247,6 +257,7 @@ struct RootView: View { // First launch (or an emptied library): COMË N GO is always the first song. Prefer the // real ingested track over the demo of the same name; queue its whole playlist from there. + let tracks = (try? modelContext.fetch(FetchDescriptor())) ?? [] let candidates = tracks.filter { $0.title.localizedCaseInsensitiveContains("COMË N GO") } guard let seed = candidates.first(where: { !$0.isDemo }) ?? candidates.first, let playlist = seed.playlist else { return } diff --git a/App/Continuity/Views/SearchView.swift b/App/Continuity/Views/SearchView.swift index 3947e58..7e834c6 100644 --- a/App/Continuity/Views/SearchView.swift +++ b/App/Continuity/Views/SearchView.swift @@ -200,7 +200,7 @@ struct SearchView: View { .font(.body) .padding(.horizontal, 14) .padding(.vertical, 9) - .background(.thinMaterial, in: Capsule()) + .continuityGlassCapsule() .fixedSize(horizontal: true, vertical: false) .frame(maxWidth: .infinity, alignment: .leading) .animation(.snappy(duration: 0.2), value: model.query) @@ -252,7 +252,7 @@ struct SearchView: View { Label(message, systemImage: "exclamationmark.triangle.fill") .font(.footnote) .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .continuityGlass(cornerRadius: 12) .padding() } } @@ -376,19 +376,50 @@ private struct CatalogArtwork: View { let url: URL? var body: some View { - AsyncImage(url: url) { image in - image.resizable().scaledToFill() - } placeholder: { - RoundedRectangle(cornerRadius: 8).fill(.quaternary) - .overlay { Image(systemName: "music.note").foregroundStyle(.secondary) } + Group { + if let url { + // Same shared decode + bounded cache as library artwork: search results scroll, + // and `AsyncImage` re-fetched and re-decoded every row that came back on screen. + CachedArtworkImage(url: url, cornerRadius: 8, cropsLetterbox: false) { + placeholder + } + } else { + placeholder + } } .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var placeholder: some View { + RoundedRectangle(cornerRadius: 8).fill(.quaternary) + .overlay { Image(systemName: "music.note").foregroundStyle(.secondary) } } } // MARK: - Custom keyboard +/// One shared, pre-armed feedback generator for the in-app keyboard. +/// +/// The keyboard used to hold `let haptic = UIImpactFeedbackGenerator(...)` as a stored property, +/// which cost twice: the Taptic Engine was re-armed from cold on every keypress (so the first +/// tap of each was late), and — because `SearchView.body` re-runs on every keystroke — the new +/// object reference made SwiftUI treat the keyboard as changed and re-evaluate all ~40 keys, +/// which is exactly what the `SuggestionBar` leaf split was meant to prevent. +@MainActor +private enum KeyboardHaptics { + private static let generator: UIImpactFeedbackGenerator = { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + return generator + }() + + /// Fires, then re-arms for the next keypress. + static func tap() { + generator.impactOccurred() + generator.prepare() + } +} + /// The app's own keyboard: a QWERTY grid rendered in SwiftUI (the system keyboard never /// appears). Its suggestion bar and space-bar autocorrect run against the catalog vocabulary /// via `CatalogAutocorrect`, so it repairs toward music words, not English ones. @@ -399,22 +430,28 @@ private struct MusicKeyboardView: View { private static let letterRows = ["qwertyuiop", "asdfghjkl", "zxcvbnm"] private static let numberRows = ["1234567890", "-'&.,?!/", "@:;()$#"] - private let haptic = UIImpactFeedbackGenerator(style: .light) - var body: some View { - VStack(spacing: 7) { - // Leaf view: suggestions change on every keystroke; inlined, they re-built the - // entire ~40-button key grid per keypress instead of just this bar. - SuggestionBar(model: model, haptic: haptic) - ForEach(showNumbers ? Self.numberRows : Self.letterRows, id: \.self) { row in - keyRow(row) + // One container for the whole key grid: sibling Liquid Glass elements inside a + // container are composited in a single pass rather than each sampling what is behind + // it — which is what ~40 separate material backdrops used to cost, every frame the + // keyboard was on screen. Spacing 0 keeps neighbouring keys from merging into blobs. + GlassEffectContainer(spacing: 0) { + VStack(spacing: 7) { + // Leaf view: suggestions change on every keystroke; inlined, they re-built the + // entire ~40-button key grid per keypress instead of just this bar. + SuggestionBar(model: model) + ForEach(showNumbers ? Self.numberRows : Self.letterRows, id: \.self) { row in + keyRow(row) + } + bottomRow } - bottomRow } .padding(.horizontal, 4) .padding(.top, 8) .padding(.bottom, 6) - .background(.regularMaterial) + // The plane the glass keys sit on stays an opaque fill — Liquid Glass layered directly + // on more Liquid Glass is exactly what Apple's guidance rules out. + .background(Color(uiColor: .secondarySystemBackground)) } private func keyRow(_ characters: String) -> some View { @@ -451,7 +488,7 @@ private struct MusicKeyboardView: View { private func key(_ label: String = "", symbol: String? = nil, width: CGFloat? = nil, prominent: Bool = false, action: @escaping () -> Void) -> some View { Button { - haptic.impactOccurred() + KeyboardHaptics.tap() action() } label: { Group { @@ -464,11 +501,11 @@ private struct MusicKeyboardView: View { .font(label.count > 1 ? .subheadline : .title3) .frame(maxWidth: width ?? .infinity) .frame(width: width, height: 42) - .background( - prominent ? AnyShapeStyle(Color.accentColor.opacity(0.85)) : AnyShapeStyle(.thinMaterial), - in: RoundedRectangle(cornerRadius: 6) - ) .foregroundStyle(prominent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .glassEffect( + prominent ? .regular.tint(Color.accentColor).interactive() : .regular.interactive(), + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) } .buttonStyle(.plain) // Holding backspace repeats, like the real thing. @@ -479,7 +516,6 @@ private struct MusicKeyboardView: View { /// Leaf: the keyboard's only per-keystroke invalidation surface (see MusicKeyboardView.body). private struct SuggestionBar: View { let model: CatalogSearchModel - let haptic: UIImpactFeedbackGenerator var body: some View { HStack(spacing: 6) { @@ -490,7 +526,7 @@ private struct SuggestionBar: View { } else { ForEach(suggestions, id: \.self) { word in Button { - haptic.impactOccurred() + KeyboardHaptics.tap() model.accept(suggestion: word) } label: { Text(word) @@ -498,7 +534,7 @@ private struct SuggestionBar: View { .lineLimit(1) .frame(maxWidth: .infinity) .frame(height: 32) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .continuityGlass(cornerRadius: 8, interactive: true) } .buttonStyle(.plain) } diff --git a/App/Continuity/Views/Theme.swift b/App/Continuity/Views/Theme.swift index c95c44c..a6964cb 100644 --- a/App/Continuity/Views/Theme.swift +++ b/App/Continuity/Views/Theme.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import ImageIO import CoreImage /// Lightweight visual helpers: deterministic artwork gradients from a seed, time formatting, @@ -32,15 +33,134 @@ enum Theme { } } +// MARK: - Liquid Glass + extension View { /// Continuity's standard Liquid Glass surface. Centralised so the exact iOS 26 /// `glassEffect` API is touched in one spot. + /// + /// Everything glass-looking in the app goes through this (or `continuityGlassCapsule`) — + /// no `Material` stand-ins. A material is a static blur+vibrancy layer; Liquid Glass is a + /// real system effect that refracts what's behind it, reacts to motion, and — inside a + /// `GlassEffectContainer` — is composited for the whole group in one pass. func continuityGlass(cornerRadius: CGFloat = 22, interactive: Bool = false) -> some View { self.glassEffect(interactive ? .regular.interactive() : .regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } + + /// Capsule-shaped Liquid Glass — the pill/badge/chip form of `continuityGlass`. + func continuityGlassCapsule(interactive: Bool = false) -> some View { + self.glassEffect(interactive ? .regular.interactive() : .regular, in: Capsule()) + } +} + +// MARK: - Image decoding + +/// Shared image decode helpers. Every artwork path in the app funnels through here so nothing +/// ever decodes a full-resolution bitmap for a 44 pt row (a 1400 px embedded cover is ~7.8 MB +/// of RAM; the thumbnail it's drawn at is ~70 KB). +enum ArtworkDecoder { + /// Decodes `data` directly at a bounded size via ImageIO, rather than decoding full-size + /// and letting the compositor scale it down. + static func image(from data: Data, maxPixel: CGFloat) -> UIImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + return UIImage(data: data) + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + // Decode now, on this background thread — not lazily on the main thread at draw time. + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixel, + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return UIImage(data: data) + } + return UIImage(cgImage: cgImage) + } + + /// Loads bytes for a remote or on-disk artwork URL. + static func data(for url: URL) async -> Data? { + if url.isFileURL { + return try? Data(contentsOf: url, options: .mappedIfSafe) + } + guard let (data, response) = try? await URLSession.shared.data(from: url) else { return nil } + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { return nil } + return data + } + + /// Rough decoded-bitmap size, for NSCache cost accounting. + static func byteCost(_ image: UIImage) -> Int { + guard let cgImage = image.cgImage else { return 1 } + return max(1, cgImage.bytesPerRow * cgImage.height) + } +} + +/// Process-wide CoreImage context. Building one per render compiles the filter kernels and +/// allocates its backing resources every time — hundreds of milliseconds of setup to run a +/// 160 px blur. `CIContext` is documented as thread-safe, so one instance serves every caller. +enum SharedCIContext { + static let shared = CIContext(options: [.workingColorSpace: NSNull()]) } +/// Bounded, shared, decoded-artwork cache behind every artwork view in the app. +/// +/// This replaces `AsyncImage`, which caches nothing of its own: each row that scrolled back +/// into view re-hit the network stack and re-decoded the JPEG, and the same cover shown in a +/// list row, the mini player and Now Playing decoded three times over. Here one decode per URL +/// is shared by every view, bounded by count and bytes, and dropped automatically under memory +/// pressure (NSCache) so it can never contribute to a jetsam. +@MainActor +final class ArtworkImageStore { + static let shared = ArtworkImageStore() + + /// Artwork is drawn at most at Now Playing's 280 pt (≈840 px on a 3× screen); anything + /// larger is bytes we pay for and never see. + private static let maxPixelSize: CGFloat = 900 + + private let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 180 + cache.totalCostLimit = 48 * 1024 * 1024 + return cache + }() + + /// One load per URL even when several views ask at once (a track's cover is typically + /// requested by a row, the mini player and Now Playing in the same frame). + private var inFlight: [URL: Task] = [:] + + private init() {} + + /// Synchronous cache hit, so a re-created row draws its art immediately instead of + /// flashing the placeholder for a frame. + func cachedImage(for url: URL) -> UIImage? { + cache.object(forKey: url as NSURL) + } + + func image(for url: URL) async -> UIImage? { + if let hit = cachedImage(for: url) { return hit } + if let existing = inFlight[url] { return await existing.value } + + let maxPixel = Self.maxPixelSize + let task = Task { () -> UIImage? in + await Task.detached(priority: .userInitiated) { () -> UIImage? in + guard let data = await ArtworkDecoder.data(for: url) else { return nil } + return ArtworkDecoder.image(from: data, maxPixel: maxPixel) + }.value + } + inFlight[url] = task + let image = await task.value + inFlight[url] = nil + if let image { + cache.setObject(image, forKey: url as NSURL, cost: ArtworkDecoder.byteCost(image)) + } + return image + } +} + +// MARK: - Backdrop + /// Immersive full-bleed backdrop built from the current track's album art — a hybrid of the /// Apple Music and Spotify treatments: a smooth vertical gradient sampled from the artwork's /// dominant colors as the base (guarantees full coverage + legibility), with a softly blurred @@ -111,6 +231,13 @@ struct BackdropStyle: Equatable { let colors: [Color] /// Small gaussian-blurred render of the art (compositor-upscaled full-screen). let blurredArt: UIImage + + /// Identity comparison on the image: styles are cached per URL and handed out as the same + /// instance, so pointer equality is exact here — and it avoids `UIImage.isEqual`, which can + /// fall through to comparing pixel data on every SwiftUI diff. + static func == (lhs: BackdropStyle, rhs: BackdropStyle) -> Bool { + lhs.colors == rhs.colors && lhs.blurredArt === rhs.blurredArt + } } /// Samples and caches the backdrop gradient palette for `AlbumBackdrop`: fetch the sharpest @@ -119,20 +246,46 @@ struct BackdropStyle: Equatable { /// brightness pinned so white text always reads. @MainActor enum BackdropRenderer { - private static var cache: [URL: BackdropStyle] = [:] + /// Bounded and purgeable. The old plain dictionary held one blurred `UIImage` per artwork + /// URL played, for the life of the process, and never gave anything back under memory + /// pressure — a slow leak on exactly the resource the jetsam RCA was fighting for. + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 32 + return cache + }() + + /// Renders in flight, keyed by URL. `AlbumBackdrop` and `PagerBackdrop` request the SAME + /// url in the same frame (the pager hosts one inside the other), so without coalescing + /// every track change ran two artwork downloads and two gaussian renders to throw one away. + private static var inFlight: [URL: Task] = [:] static func style(for url: URL) async -> BackdropStyle? { - if let hit = cache[url] { return hit } - guard let source = await fetchBestArtwork(url) else { return nil } - let style = await Task.detached(priority: .userInitiated) { () -> BackdropStyle? in - guard let colors = samplePalette(source) else { return nil } - return BackdropStyle(colors: colors, blurredArt: blurredRender(source)) - }.value + if let hit = cache.object(forKey: url as NSURL) { return hit.style } + if let existing = inFlight[url] { return await existing.value } + + let task = Task { () -> BackdropStyle? in + guard let source = await fetchBestArtwork(url) else { return nil } + return await Task.detached(priority: .userInitiated) { () -> BackdropStyle? in + guard let colors = samplePalette(source) else { return nil } + return BackdropStyle(colors: colors, blurredArt: blurredRender(source)) + }.value + } + inFlight[url] = task + let style = await task.value + inFlight[url] = nil + guard let style else { return nil } - cache[url] = style + cache.setObject(CachedBackdropStyle(style), forKey: url as NSURL) return style } + /// NSCache stores objects, so the value struct rides in a box. + private final class CachedBackdropStyle { + let style: BackdropStyle + init(_ style: BackdropStyle) { self.style = style } + } + /// Scale-fill into a 160 px square, then a real gaussian (clamped so edges don't darken) — /// the compositor upscale of the result reads as a soft radius-60-style blur. Rendered once /// per URL; never per frame. @@ -150,8 +303,7 @@ enum BackdropRenderer { let blurred = ci.clampedToExtent() .applyingGaussianBlur(sigma: 16) .cropped(to: ci.extent) - let context = CIContext(options: nil) - guard let cg = context.createCGImage(blurred, from: blurred.extent) else { return small } + guard let cg = SharedCIContext.shared.createCGImage(blurred, from: blurred.extent) else { return small } return UIImage(cgImage: cg) } @@ -169,9 +321,10 @@ enum BackdropRenderer { } candidates.append(url) for candidate in candidates { - guard let (data, response) = try? await URLSession.shared.data(from: candidate) else { continue } - if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { continue } - if let image = UIImage(data: data) { return image } + guard let data = await ArtworkDecoder.data(for: candidate) else { continue } + // Bounded decode: the palette is an area average and the blur renders at 160 px, so + // fully decoding a 1280×720 maxres frame (≈3.7 MB of bitmap) buys nothing. + if let image = ArtworkDecoder.image(from: data, maxPixel: 512) { return image } } return nil } @@ -194,10 +347,9 @@ enum BackdropRenderer { kCIInputExtentKey: CIVector(cgRect: rect), ]), let output = filter.outputImage else { return nil } var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [.workingColorSpace: NSNull()]) - context.render(output, toBitmap: &bitmap, rowBytes: 4, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .RGBA8, colorSpace: CGColorSpaceCreateDeviceRGB()) + SharedCIContext.shared.render(output, toBitmap: &bitmap, rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, colorSpace: CGColorSpaceCreateDeviceRGB()) return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: 1) } @@ -212,6 +364,8 @@ enum BackdropRenderer { } } +// MARK: - Artwork tiles + /// Reusable square artwork tile (gradient + SF Symbol) used by cards, rows and Now Playing. struct ArtworkView: View { let symbol: String @@ -230,40 +384,72 @@ struct ArtworkView: View { } } -/// Artwork tile that shows real remote cover art when available (YouTube thumbnail), falling -/// back to the gradient `ArtworkView` while loading or when there is none (demo tracks). +/// Artwork tile that shows real cover art when available (a YouTube thumbnail, or artwork +/// extracted from an imported file), falling back to the gradient `ArtworkView` while loading +/// or when there is none (demo tracks). struct RemoteArtworkView: View { let url: URL? let symbol: String let seed: Int var cornerRadius: CGFloat = 16 - /// Every remote thumbnail we use is YouTube's hqdefault: a 4:3 frame with the 16:9 video - /// letterboxed inside (baked black bars, 12.5% top + bottom). Zooming the image by 4/3 pushes - /// those bars outside the clip so tiles show only the picture — on by default because it's true - /// of all our art. Aspect ratio is preserved (uniform scale). The gradient/symbol fallback - /// (no URL / still loading) is a real square and is unaffected. + /// Remote thumbnails are YouTube's hqdefault: a 4:3 frame with the 16:9 video letterboxed + /// inside (baked black bars, 12.5% top + bottom). Zooming the image by 4/3 pushes those bars + /// outside the clip so tiles show only the picture. Aspect ratio is preserved (uniform + /// scale). Artwork extracted from an imported file is a real cover, not a video frame, so + /// the zoom is applied to remote thumbnails only — cropping 12.5% off a real album cover + /// loses picture. The gradient/symbol fallback is a real square and is unaffected. var cropsLetterbox: Bool = true var body: some View { if let url { - AsyncImage(url: url) { phase in - if let image = phase.image { - // YouTube thumbs are 4:3 with letterboxing; fill the square tile. - Color.clear.overlay( - image.resizable().scaledToFill() - .scaleEffect(cropsLetterbox ? 4.0 / 3.0 : 1) - ) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - // clipShape trims pixels but NOT hit-testing: the scaledToFill (and - // letterbox-zoom) overflow would otherwise extend the enclosing - // button/row's tap area far past the visible tile. - .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - } else { - ArtworkView(symbol: symbol, seed: seed, cornerRadius: cornerRadius) - } + CachedArtworkImage( + url: url, + cornerRadius: cornerRadius, + // A file URL is embedded cover art, never a letterboxed video frame. + cropsLetterbox: cropsLetterbox && !url.isFileURL + ) { + ArtworkView(symbol: symbol, seed: seed, cornerRadius: cornerRadius) } } else { ArtworkView(symbol: symbol, seed: seed, cornerRadius: cornerRadius) } } } + +/// Draws one artwork URL through `ArtworkImageStore` — a shared decode, a bounded cache, and a +/// synchronous cache hit so recycled rows never flash their placeholder. +struct CachedArtworkImage: View { + let url: URL + let cornerRadius: CGFloat + let cropsLetterbox: Bool + @ViewBuilder let placeholder: Placeholder + + @State private var loaded: UIImage? + + var body: some View { + Group { + if let image = loaded ?? ArtworkImageStore.shared.cachedImage(for: url) { + Color.clear.overlay( + Image(uiImage: image) + .resizable() + .scaledToFill() + .scaleEffect(cropsLetterbox ? 4.0 / 3.0 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + // clipShape trims pixels but NOT hit-testing: the scaledToFill (and + // letterbox-zoom) overflow would otherwise extend the enclosing button/row's + // tap area far past the visible tile. + .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else { + placeholder + } + } + .task(id: url) { + if let hit = ArtworkImageStore.shared.cachedImage(for: url) { + loaded = hit + return + } + loaded = await ArtworkImageStore.shared.image(for: url) + } + } +} diff --git a/App/Continuity/Views/TransitionVisualizationView.swift b/App/Continuity/Views/TransitionVisualizationView.swift index bdec08d..db85687 100644 --- a/App/Continuity/Views/TransitionVisualizationView.swift +++ b/App/Continuity/Views/TransitionVisualizationView.swift @@ -1,35 +1,44 @@ import SwiftUI import Domain +import Playback import ContinuityCore /// A compact, at-a-glance picture of what the DJ transition between two tracks does: the /// crossfade shape, how the two beat grids line up, and the tempo / key / bass moves being -/// applied. Shown in the Now Playing sheet both while a blend is live (with a moving playhead) -/// and just before a scheduled one begins. +/// applied. Shown on Now Playing both while a blend is live (with a moving playhead) and just +/// before a scheduled one begins. /// /// All the musical facts come from `TransitionPreview.make(...)`, so this view never disagrees /// with what the audio engine actually does — it only handles presentation. +/// +/// **Nothing here is a function of the 20 Hz playback clock.** The two moving parts — the +/// countdown to a scheduled blend and the playhead sweeping a live one — are separate leaf +/// views that read the player themselves, so a tick invalidates a `Text` or a 1 pt rule rather +/// than rebuilding the preview, re-reading both tracks' `beatTimes` out of SwiftData, and +/// redrawing two `Canvas`es twenty times a second. struct TransitionVisualizationView: View { let settings: TransitionSettings let outgoing: Track let incoming: Track let isLive: Bool - let liveProgress: Double - let secondsUntil: TimeInterval? /// Computed once at construction: `body` reads it in several places (graph + chips), and - /// as a computed property `TransitionPreview.make` re-ran on every access — 3×+ per frame - /// during live blends. Pure function of the init inputs, so a stored value is identical. + /// as a computed property `TransitionPreview.make` re-ran on every access. private let preview: TransitionPreview + /// The crossfade polyline samples — a pure function of the curve, so they never need to be + /// recomputed inside the `Canvas` draw closure. + private let curveSamples: [CrossfadeGains] + /// Beat tick positions on the shared `0...1` blend axis, resolved once. `Track.beatTimes` is + /// a SwiftData array property (hundreds to thousands of doubles); reading it per redraw was + /// the single most expensive thing on the Now Playing screen. + private let outgoingBeats: [Double] + private let incomingBeats: [Double] - init(settings: TransitionSettings, outgoing: Track, incoming: Track, - isLive: Bool, liveProgress: Double, secondsUntil: TimeInterval?) { + init(settings: TransitionSettings, outgoing: Track, incoming: Track, isLive: Bool) { self.settings = settings self.outgoing = outgoing self.incoming = incoming self.isLive = isLive - self.liveProgress = liveProgress - self.secondsUntil = secondsUntil self.preview = TransitionPreview.make( curve: settings.curve, duration: settings.durationSeconds, @@ -41,6 +50,17 @@ struct TransitionVisualizationView: View { harmonicEnabled: settings.harmonicMixingEnabled, bassSwapEnabled: settings.bassSwapEnabled ) + self.curveSamples = settings.curve.samples(count: 60) + self.outgoingBeats = BeatWindow.outgoingPositions( + beats: outgoing.beatTimes, + windowEnd: outgoing.audibleEndSeconds ?? outgoing.durationSeconds, + duration: settings.durationSeconds + ) + self.incomingBeats = BeatWindow.incomingPositions( + beats: incoming.beatTimes, + windowStart: incoming.audibleStartSeconds ?? 0, + duration: settings.durationSeconds + ) } var body: some View { @@ -62,13 +82,8 @@ struct TransitionVisualizationView: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .lineLimit(1) - } else if let secondsUntil { - Text("Transition in \(Int(secondsUntil.rounded()))s") - .font(.caption.weight(.semibold)) - .foregroundStyle(.white.opacity(0.85)) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(.ultraThinMaterial, in: Capsule()) + } else { + TransitionCountdownPill() } } @@ -79,9 +94,8 @@ struct TransitionVisualizationView: View { /// While live, a vertical playhead sweeps across at the current transition progress. private var crossfadeGraph: some View { Canvas { context, size in - let samples = preview.curve.samples(count: 60) - guard samples.count > 1 else { return } - let stepX = size.width / CGFloat(samples.count - 1) + guard curveSamples.count > 1 else { return } + let stepX = size.width / CGFloat(curveSamples.count - 1) func point(_ index: Int, _ gain: Double) -> CGPoint { CGPoint(x: CGFloat(index) * stepX, y: (1 - CGFloat(gain)) * size.height) @@ -89,7 +103,7 @@ struct TransitionVisualizationView: View { var outgoingPath = Path() var incomingPath = Path() - for (i, sample) in samples.enumerated() { + for (i, sample) in curveSamples.enumerated() { let outPoint = point(i, sample.outgoing) let inPoint = point(i, sample.incoming) if i == 0 { @@ -102,18 +116,12 @@ struct TransitionVisualizationView: View { } context.stroke(outgoingPath, with: .color(.white.opacity(0.85)), lineWidth: 2.5) context.stroke(incomingPath, with: .color(Color.accentColor), lineWidth: 2.5) - - if isLive { - let x = CGFloat(min(max(liveProgress, 0), 1)) * size.width - var playhead = Path() - playhead.move(to: CGPoint(x: x, y: 0)) - playhead.addLine(to: CGPoint(x: x, y: size.height)) - context.stroke(playhead, with: .color(.white.opacity(0.5)), lineWidth: 1) - } } .frame(height: 72) + // Leaf overlay: the sweeping playhead is the only part of the graph tied to the blend + // clock, so it moves on its own without redrawing the curves underneath it. + .overlay { if isLive { BlendPlayhead() } } .allowsHitTesting(false) - .animation(.linear(duration: 0.25), value: liveProgress) } // MARK: Beat grid @@ -122,31 +130,27 @@ struct TransitionVisualizationView: View { /// incoming track's intro beats (bottom, accent), each mapped into the shared `0...1` blend /// axis. A track with no detected beats simply contributes no ticks. @ViewBuilder private var beatGrid: some View { - if !outgoing.beatTimes.isEmpty || !incoming.beatTimes.isEmpty { + if !outgoingBeats.isEmpty || !incomingBeats.isEmpty { Canvas { context, size in - let duration = settings.durationSeconds - let windowEnd = outgoing.audibleEndSeconds ?? outgoing.durationSeconds - let windowStart = incoming.audibleStartSeconds ?? 0 - let outgoingBeats = BeatWindow.outgoingPositions( - beats: outgoing.beatTimes, windowEnd: windowEnd, duration: duration) - let incomingBeats = BeatWindow.incomingPositions( - beats: incoming.beatTimes, windowStart: windowStart, duration: duration) let tickHeight = size.height / 2 - 1 + var topTicks = Path() for position in outgoingBeats { let x = CGFloat(position) * size.width - var tick = Path() - tick.move(to: CGPoint(x: x, y: 0)) - tick.addLine(to: CGPoint(x: x, y: tickHeight)) - context.stroke(tick, with: .color(.white.opacity(0.5)), lineWidth: 1) + topTicks.move(to: CGPoint(x: x, y: 0)) + topTicks.addLine(to: CGPoint(x: x, y: tickHeight)) } + // One stroked path per row rather than one per tick: a dense grid is hundreds of + // beats, and each separate stroke is its own draw call. + context.stroke(topTicks, with: .color(.white.opacity(0.5)), lineWidth: 1) + + var bottomTicks = Path() for position in incomingBeats { let x = CGFloat(position) * size.width - var tick = Path() - tick.move(to: CGPoint(x: x, y: size.height - tickHeight)) - tick.addLine(to: CGPoint(x: x, y: size.height)) - context.stroke(tick, with: .color(Color.accentColor.opacity(0.6)), lineWidth: 1) + bottomTicks.move(to: CGPoint(x: x, y: size.height - tickHeight)) + bottomTicks.addLine(to: CGPoint(x: x, y: size.height)) } + context.stroke(bottomTicks, with: .color(Color.accentColor.opacity(0.6)), lineWidth: 1) } .frame(height: 14) .allowsHitTesting(false) @@ -157,35 +161,41 @@ struct TransitionVisualizationView: View { /// The applied transition moves as wrapping capsules — tempo nudge, key match, bass swap, /// vocal handling (only meaningful when both tracks have stems), and the curve + duration. + /// + /// Wrapped in a `GlassEffectContainer`: sibling glass elements inside one container are + /// composited in a single pass instead of each sampling the backdrop on its own. Spacing 0 + /// so neighbouring chips stay separate capsules rather than merging into one blob. private var chips: some View { - ChipFlowLayout(spacing: 8) { - if let tempo = preview.tempo { - chip { - Text("\(Int(tempo.outgoingBPM))→\(Int(tempo.incomingBPM)) BPM " - + String(format: "%+.1f%%", tempo.percentAdjust)) + GlassEffectContainer(spacing: 0) { + ChipFlowLayout(spacing: 8) { + if let tempo = preview.tempo { + chip { + Text("\(Int(tempo.outgoingBPM))→\(Int(tempo.incomingBPM)) BPM " + + String(format: "%+.1f%%", tempo.percentAdjust)) + } } - } - if let key = preview.key { - chip { - HStack(spacing: 4) { - Text("\(key.outgoingCode)→\(key.incomingCode)") - if key.compatible { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - } - if key.appliedShiftSemitones != 0 { - Text(String(format: "(%+d st)", key.appliedShiftSemitones)) + if let key = preview.key { + chip { + HStack(spacing: 4) { + Text("\(key.outgoingCode)→\(key.incomingCode)") + if key.compatible { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } + if key.appliedShiftSemitones != 0 { + Text(String(format: "(%+d st)", key.appliedShiftSemitones)) + } } } } + if settings.bassSwapEnabled { + chip { Label("Bass swap", systemImage: "dial.low.fill") } + } + if outgoing.hasStems && incoming.hasStems { + chip { Text(settings.vocalMode.label) } + } + chip { Text("\(curveName(settings.curve)) · \(Int(settings.durationSeconds))s") } } - if settings.bassSwapEnabled { - chip { Label("Bass swap", systemImage: "dial.low.fill") } - } - if outgoing.hasStems && incoming.hasStems { - chip { Text(settings.vocalMode.label) } - } - chip { Text("\(curveName(settings.curve)) · \(Int(settings.durationSeconds))s") } } } @@ -195,7 +205,7 @@ struct TransitionVisualizationView: View { .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(.ultraThinMaterial, in: Capsule()) + .continuityGlassCapsule() } /// Local mirror of `TransitionSettingsView`'s private curve-name mapping so the labels match. @@ -208,7 +218,46 @@ struct TransitionVisualizationView: View { } } -/// A minimal wrapping layout so the transition chips flow onto multiple lines within the sheet. +// MARK: - Tick-driven leaves + +/// Leaf: the countdown to a scheduled blend. Reads the player's 1 Hz coarse countdown, so it +/// re-renders about once a second and never invalidates the panel around it. +private struct TransitionCountdownPill: View { + @Environment(Player.self) private var player + + var body: some View { + if let seconds = player.transitionCountdownSeconds { + Text("Transition in \(seconds)s") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .continuityGlassCapsule() + } + } +} + +/// Leaf: the playhead sweeping the crossfade graph during a live blend. The only view on the +/// screen that reads `transitionProgress`. +private struct BlendPlayhead: View { + @Environment(Player.self) private var player + + var body: some View { + let progress = min(max(player.transitionProgress, 0), 1) + GeometryReader { geometry in + Rectangle() + .fill(.white.opacity(0.5)) + .frame(width: 1) + .offset(x: geometry.size.width * CGFloat(progress)) + } + .animation(.linear(duration: 0.25), value: progress) + .allowsHitTesting(false) + } +} + +// MARK: - Layout + +/// A minimal wrapping layout so the transition chips flow onto multiple lines within the panel. private struct ChipFlowLayout: Layout { var spacing: CGFloat = 8 diff --git a/Packages/ContinuityKit/Sources/Domain/AudioCache.swift b/Packages/ContinuityKit/Sources/Domain/AudioCache.swift index c1c73fd..6c6d6a7 100644 --- a/Packages/ContinuityKit/Sources/Domain/AudioCache.swift +++ b/Packages/ContinuityKit/Sources/Domain/AudioCache.swift @@ -10,7 +10,13 @@ public enum AudioCache { /// /// Under Application Support (not Caches) so the library survives relaunch and isn't evicted /// under storage pressure. Excluded from iCloud backup — it's large and re-downloadable. - public static var directory: URL { + /// + /// `static let`, not a computed property: this is read on every `url(forRelativePath:)` — + /// per track row, per launch-time resume pass, per deck load — and as a computed property + /// each of those paid a `createDirectory` + `setResourceValues` (an `setattrlist`) syscall + /// pair. Swift's lazy static initialization runs the directory setup exactly once per + /// process, on first access, which is all it was ever meant to do. + public static let directory: URL = { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! var dir = base.appendingPathComponent("ContinuityAudio", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -18,7 +24,7 @@ public enum AudioCache { values.isExcludedFromBackup = true try? dir.setResourceValues(values) return dir - } + }() /// The on-disk URL for a given video's audio, e.g. `.../ContinuityAudio/.`. public static func fileURL(videoID: String, container: String) -> URL { diff --git a/Packages/ContinuityKit/Sources/Domain/Playlist.swift b/Packages/ContinuityKit/Sources/Domain/Playlist.swift index d107456..a5cfc16 100644 --- a/Packages/ContinuityKit/Sources/Domain/Playlist.swift +++ b/Packages/ContinuityKit/Sources/Domain/Playlist.swift @@ -74,8 +74,19 @@ public final class Playlist { /// True when every track is a synthesized demo (the seeded sample albums). public var isDemo: Bool { !tracks.isEmpty && tracks.allSatisfy(\.isDemo) } - /// Cover art for the playlist card: the first track that has real artwork. + /// Cover art for the playlist card: the first track (in play order) that has real artwork. + /// + /// A single pass rather than `orderedTracks`, which sorts and copies the whole relationship + /// array. Every playlist card's body reads this, so a grid of N playlists paid N sorts on + /// every re-render — and each candidate's `artworkURL` is built at most once. public var artworkURL: URL? { - orderedTracks.lazy.compactMap(\.artworkURL).first + var bestIndex = Int.max + var bestURL: URL? + for track in tracks { + guard track.sortIndex < bestIndex, let url = track.artworkURL else { continue } + bestIndex = track.sortIndex + bestURL = url + } + return bestURL } } diff --git a/Packages/ContinuityKit/Sources/Domain/StemCache.swift b/Packages/ContinuityKit/Sources/Domain/StemCache.swift index a234513..c3f7538 100644 --- a/Packages/ContinuityKit/Sources/Domain/StemCache.swift +++ b/Packages/ContinuityKit/Sources/Domain/StemCache.swift @@ -13,7 +13,11 @@ public enum StemCache { /// 1000-song library stays under the ~15 GB target even when the stem cache is full. public static let budgetBytes: Int64 = 8_000_000_000 - public static var directory: URL { + /// Memoized (`static let`, lazily initialized once per process): `stemFile`, `markUsed`, + /// `removeStems` and `url(forRelativePath:)` all read this in loops, and as a computed + /// property every one of those iterations paid a `createDirectory` + `setResourceValues` + /// syscall pair. + public static let directory: URL = { // Under Application Support (not Caches) so stems persist across launches. Excluded from // iCloud backup — they're large and re-derivable from the cached audio. var dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -23,7 +27,7 @@ public enum StemCache { values.isExcludedFromBackup = true try? dir.setResourceValues(values) return dir - } + }() public static func vocalsURL(key: String) -> URL { directory.appendingPathComponent("\(key)-vocals.m4a") } public static func accompanimentURL(key: String) -> URL { directory.appendingPathComponent("\(key)-accompaniment.m4a") } diff --git a/Packages/ContinuityKit/Sources/Domain/Track.swift b/Packages/ContinuityKit/Sources/Domain/Track.swift index d8ea107..0708177 100644 --- a/Packages/ContinuityKit/Sources/Domain/Track.swift +++ b/Packages/ContinuityKit/Sources/Domain/Track.swift @@ -11,7 +11,11 @@ public enum PrepState: String, Codable, Sendable { /// On-disk store for artwork extracted from imported files' embedded metadata. Mirrors /// `StemCache`: flat files in one Application Support directory, excluded from backup. public enum ArtworkStore { - public static var directory: URL { + /// Memoized (`static let`, lazily initialized once per process). `Track.artworkURL` reads + /// this, and that runs in every artwork view body — i.e. per row, per frame of a scroll — + /// so a computed property meant two filesystem syscalls (`createDirectory` + + /// `setResourceValues`) on the main thread for every visible row. + public static let directory: URL = { var dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Artwork", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -19,7 +23,7 @@ public enum ArtworkStore { values.isExcludedFromBackup = true try? dir.setResourceValues(values) return dir - } + }() } @Model diff --git a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift index bf6ea13..d3f3a04 100644 --- a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift +++ b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift @@ -9,15 +9,19 @@ import SwiftData /// /// Call AFTER the track models have been deleted and saved, so the reference check sees the /// post-deletion library. +/// +/// The surviving-key set is resolved on the main actor (it reads the model context); every +/// filesystem pass then runs off it. Enumerating and deleting hundreds of cache files is +/// exactly the kind of work that made deleting a playlist — and launching with a large +/// library — hitch. public enum LibraryCleanup { @MainActor public static func removeOrphanedFiles(keys: [String], in context: ModelContext) { - let tracks = (try? context.fetch(FetchDescriptor())) ?? [] - let referenced = Set(tracks.map(\.stemKey)) - for key in Set(keys) where !referenced.contains(key) { - removeCachedFiles(key: key) - } + let referenced = referencedStemKeys(in: context) + let orphaned = Set(keys).subtracting(referenced) + guard !orphaned.isEmpty else { return } + Task.detached(priority: .utility) { removeCachedFiles(keys: orphaned) } } /// Launch-time sweep: removes any cached file whose stem key has no surviving track. @@ -25,53 +29,69 @@ public enum LibraryCleanup { /// on disk after the delete-time cleanup had already run. @MainActor public static func sweepOrphanedFiles(in context: ModelContext) { - guard let tracks = try? context.fetch(FetchDescriptor()) else { return } - let referenced = Set(tracks.map(\.stemKey)) + let referenced = referencedStemKeys(in: context) + Task.detached(priority: .utility) { sweep(referenced: referenced) } + } + + /// Stem keys still claimed by a surviving track. Only `id` and `youtubeVideoID` feed + /// `Track.stemKey`, so the fetch asks for those rather than hydrating every row's analysis + /// arrays. + @MainActor + private static func referencedStemKeys(in context: ModelContext) -> Set { + var descriptor = FetchDescriptor() + descriptor.propertiesToFetch = [\.id, \.youtubeVideoID] + guard let tracks = try? context.fetch(descriptor) else { return [] } + return Set(tracks.map(\.stemKey)) + } - if let files = try? FileManager.default.contentsOfDirectory( - at: AudioCache.directory, includingPropertiesForKeys: nil - ) { - for file in files where !referenced.contains(file.deletingPathExtension().lastPathComponent) { + // MARK: - Filesystem passes (off the main actor) + + private static func sweep(referenced: Set) { + for file in contents(of: AudioCache.directory) { + if !referenced.contains(file.deletingPathExtension().lastPathComponent) { try? FileManager.default.removeItem(at: file) } } - if let files = try? FileManager.default.contentsOfDirectory( - at: StemCache.directory, includingPropertiesForKeys: nil - ) { - for file in files { - // Stem names are "-vocals.*" / "-accompaniment.*". Suffix-only parsing - // (a key can contain "-vocals" as a substring); files that don't match the - // stem naming scheme at all are junk in this directory and stay sweepable. - let base = file.deletingPathExtension().lastPathComponent - let key = StemCache.key(fromStemBaseName: base) ?? base - if !referenced.contains(key) { - try? FileManager.default.removeItem(at: file) - } + for file in contents(of: StemCache.directory) { + // Stem names are "-vocals.*" / "-accompaniment.*". Suffix-only parsing + // (a key can contain "-vocals" as a substring); files that don't match the + // stem naming scheme at all are junk in this directory and stay sweepable. + let base = file.deletingPathExtension().lastPathComponent + let key = StemCache.key(fromStemBaseName: base) ?? base + if !referenced.contains(key) { + try? FileManager.default.removeItem(at: file) } } - if let files = try? FileManager.default.contentsOfDirectory( - at: ArtworkStore.directory, includingPropertiesForKeys: nil - ) { - for file in files where !referenced.contains(file.deletingPathExtension().lastPathComponent) { + for file in contents(of: ArtworkStore.directory) { + if !referenced.contains(file.deletingPathExtension().lastPathComponent) { try? FileManager.default.removeItem(at: file) } } } - /// Deletes every cached artifact for one stem key: audio (extension varies, match by + /// Deletes every cached artifact for a set of stem keys: audio (extension varies, match by /// basename), stems, and extracted artwork. - private static func removeCachedFiles(key: String) { - if let files = try? FileManager.default.contentsOfDirectory( - at: AudioCache.directory, includingPropertiesForKeys: nil - ) { - for file in files where file.deletingPathExtension().lastPathComponent == key { + /// + /// Takes the whole key set at once: the audio cache is a flat directory that has to be + /// enumerated to match a basename against an unknown extension, and doing that per key + /// meant one full listing per deleted track (a hundred listings to delete a playlist). + private static func removeCachedFiles(keys: Set) { + for file in contents(of: AudioCache.directory) { + if keys.contains(file.deletingPathExtension().lastPathComponent) { try? FileManager.default.removeItem(at: file) } } - StemCache.removeStems(key: key) - try? FileManager.default.removeItem( - at: ArtworkStore.directory.appendingPathComponent("\(key).jpg")) + for key in keys { + StemCache.removeStems(key: key) + try? FileManager.default.removeItem( + at: ArtworkStore.directory.appendingPathComponent("\(key).jpg")) + } + } + + private static func contents(of directory: URL) -> [URL] { + (try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil)) ?? [] } } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift index 9be684d..7a1792a 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift @@ -33,9 +33,36 @@ extension PreparationQueue { } // Budget pass on every queue move (not just post-separation): catches overage that // accrued outside this code path — e.g. gigabytes of legacy float32 stems. - let protected = protectedStemKeys - Task.detached(priority: .utility) { + scheduleBudgetPass() + } + + /// Runs `StemCache.enforceBudget` off the main actor, at most one pass at a time. + /// + /// `ensureStems` fires on every queue move, and each pass enumerates the whole stem + /// directory reading per-file size and modification dates. Rapid skips used to stack a + /// fresh full scan per skip, all racing to delete the same files. Requests that arrive + /// during a pass collapse into a single trailing pass, so nothing is silently dropped. + func scheduleBudgetPass(alsoProtecting extraKey: String? = nil) { + if let extraKey { budgetExtraProtectedKeys.insert(extraKey) } + guard !budgetPassInFlight else { + budgetPassRequested = true + return + } + runBudgetPass() + } + + private func runBudgetPass() { + budgetPassInFlight = true + budgetPassRequested = false + let protected = protectedStemKeys.union(budgetExtraProtectedKeys) + budgetExtraProtectedKeys.removeAll() + Task.detached(priority: .utility) { [weak self] in StemCache.enforceBudget(protecting: protected) + await MainActor.run { + guard let self else { return } + self.budgetPassInFlight = false + if self.budgetPassRequested { self.runBudgetPass() } + } } } @@ -64,17 +91,20 @@ extension PreparationQueue { /// Brings a track's stem links in line with the disk: links cached stems that exist unlinked /// (e.g. a re-added video), clears links whose files were evicted so `hasStems` tells the /// truth and the track becomes eligible for re-separation. - func reconcileStemLinks(_ track: Track, in context: ModelContext) { + /// `index` lets a caller that reconciles many tracks at once (the launch resume pass) hand + /// in one snapshot of the cache directories instead of paying four to six `fileExists` + /// syscalls per track. + func reconcileStemLinks(_ track: Track, in context: ModelContext, using index: CacheIndex? = nil) { let key = track.stemKey if let v = track.vocalsRelativePath, let a = track.accompanimentRelativePath, - FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: v).path), - FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: a).path) { + index?.hasStemFile(v) ?? FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: v).path), + index?.hasStemFile(a) ?? FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: a).path) { return // linked and present — nothing to do } - if let v = StemCache.stemFile(key: key, kind: "vocals"), - let a = StemCache.stemFile(key: key, kind: "accompaniment") { - track.vocalsRelativePath = StemCache.relativePath(for: v) - track.accompanimentRelativePath = StemCache.relativePath(for: a) + if let v = stemRelativePath(key: key, kind: "vocals", index: index), + let a = stemRelativePath(key: key, kind: "accompaniment", index: index) { + track.vocalsRelativePath = v + track.accompanimentRelativePath = a try? context.save() } else if track.hasStems { track.vocalsRelativePath = nil @@ -83,6 +113,13 @@ extension PreparationQueue { } } + /// Existing stem file for a key/kind as a stem-cache-relative path, resolved from the batch + /// index when one was supplied and from the filesystem otherwise. + private func stemRelativePath(key: String, kind: String, index: CacheIndex?) -> String? { + if let index { return index.stemFileName(key: key, kind: kind) } + return StemCache.stemFile(key: key, kind: kind).map(StemCache.relativePath(for:)) + } + /// Separates the track into vocals + accompaniment stems (very slow, off the main actor), /// caching them and pointing the track at them. Best-effort: failure just means no vocal-aware /// transition for this track. Serialised via `stemLimiter`; after each separation the cache's @@ -151,8 +188,7 @@ extension PreparationQueue { // Budget pass after every separation. Never evict the active neighborhood or the // key we just wrote (it may not be in the protected set if the queue moved on). - let protected = await MainActor.run { self?.protectedStemKeys ?? [] } - StemCache.enforceBudget(protecting: protected.union([key])) + await MainActor.run { self?.scheduleBudgetPass(alsoProtecting: key) } let queueDrained = await MainActor.run { () -> Bool in guard let self else { return true } self.stemsInFlight.remove(key) @@ -165,3 +201,34 @@ extension PreparationQueue { } } } + +/// A snapshot of the audio + stem cache directory listings, so a pass over many tracks can +/// answer "is this file on disk?" from memory instead of one `stat` per question. +struct CacheIndex: Sendable { + private let audioFiles: Set + private let stemFiles: Set + + static func snapshot() -> CacheIndex { + CacheIndex( + audioFiles: names(in: AudioCache.directory), + stemFiles: names(in: StemCache.directory) + ) + } + + /// Files live flat in each cache directory, so the relative path is the file name. + func hasAudio(_ relativePath: String) -> Bool { audioFiles.contains(relativePath) } + func hasStemFile(_ relativePath: String) -> Bool { stemFiles.contains(relativePath) } + + /// Mirrors `StemCache.stemFile(key:kind:)` — current `.m4a` first, legacy `.caf` second. + func stemFileName(key: String, kind: String) -> String? { + for ext in ["m4a", "caf"] { + let name = "\(key)-\(kind).\(ext)" + if stemFiles.contains(name) { return name } + } + return nil + } + + private static func names(in directory: URL) -> Set { + Set((try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []) + } +} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift index 91c9f88..bb0bfac 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -111,25 +111,32 @@ public final class PreparationQueue { /// no stems yet. `.failed` tracks are left as-is for an explicit retry. public func resumePreparation(in context: ModelContext) { guard let tracks = try? context.fetch(FetchDescriptor()) else { return } + // One directory listing per cache instead of up to five `fileExists` probes per track: + // a thousand-track library meant thousands of stat calls on the main thread, at launch, + // before the first frame. + let cacheIndex = CacheIndex.snapshot() + // Demo healing used to `save()` once per track; batched into a single save at the end. + var needsSave = false for track in tracks { // Demo tracks have no source and play synthesized audio — there is nothing to ingest // or resume. Without this guard they'd be re-enqueued (they have no audio file), fail // for lack of a source, and show up as retry-able failures. Heal any that already did. if track.isDemo { - if track.prepState != .ready { track.prepState = .ready; try? context.save() } + if track.prepState != .ready { + track.prepState = .ready + needsSave = true + } continue } switch track.prepState { case .ready: - let hasAudio = track.localRelativePath.map { - FileManager.default.fileExists(atPath: AudioCache.url(forRelativePath: $0).path) - } ?? false + let hasAudio = track.localRelativePath.map { cacheIndex.hasAudio($0) } ?? false if !hasAudio { enqueue(track, in: context) // file lost/evicted → re-fetch end to end } else { // Stems are demand-driven from the play queue (`ensureStems`) — never // separated library-wide at launch. Just true-up links vs the disk. - reconcileStemLinks(track, in: context) + reconcileStemLinks(track, in: context, using: cacheIndex) backfillTrackDetails(track, in: context) } case .pending, .preparing: @@ -138,6 +145,7 @@ public final class PreparationQueue { break } } + if needsSave { try? context.save() } } // MARK: - Source sync @@ -445,6 +453,15 @@ public final class PreparationQueue { var separationAllowedAt: Date? /// At most one pending post-hold retry (ensureStems also re-fires on every track change). var stemsRetryScheduled = false + /// True while a stem-cache budget pass is running. `ensureStems` fires on every queue move + /// and each pass enumerates the whole stem directory with per-file sizes and dates, so + /// rapid skips used to stack one full scan per skip. + var budgetPassInFlight = false + /// A pass was asked for while one was running — run exactly one more when it finishes. + var budgetPassRequested = false + /// Keys a queued pass must protect on top of the play-queue neighborhood (a stem written + /// after the queue moved on), drained into the next pass. + var budgetExtraProtectedKeys: Set = [] } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+History.swift b/Packages/ContinuityKit/Sources/Playback/Player+History.swift index 77c0905..6b18a26 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+History.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+History.swift @@ -16,6 +16,9 @@ extension Player { /// and mirrors the same state to the lock screen / Control Center. Every playback /// discontinuity funnels through here, which is exactly when both need updating. func persistState() { + // Every playback discontinuity funnels through here, which is also exactly when the + // scheduled-blend countdown moves for a reason other than the clock ticking. + refreshTransitionCountdown() nowPlayingBridge.update( track: currentTrack, duration: duration, diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Queue.swift b/Packages/ContinuityKit/Sources/Playback/Player+Queue.swift index 76c6104..15582f2 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Queue.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Queue.swift @@ -6,11 +6,21 @@ extension Player { // MARK: Queue editing /// Tracks after the current one, in play order. + /// + /// Copies the slice, so callers that only need the first element should use + /// `nextUpcomingTrack` — a full-queue copy per body evaluation is not what the Now Playing + /// panel needs to answer "what blends in next?". public var upcomingTracks: [Track] { guard queue.indices.contains(currentIndex), currentIndex + 1 < queue.count else { return [] } return Array(queue[(currentIndex + 1)...]) } + /// The next track in play order, without materializing the upcoming slice. + public var nextUpcomingTrack: Track? { + let next = currentIndex + 1 + return queue.indices.contains(next) ? queue[next] : nil + } + /// Inserts (or moves, if already upcoming) the track to play immediately after the current one. public func playNext(_ track: Track) { // No current track → nothing to play "next" after; the current track itself is a no-op. diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index d3b3733..6455ca7 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -418,6 +418,9 @@ public final class Player { /// Seconds until the next automatic transition begins, or nil if none is scheduled /// (already transitioning, no next track, or the current track/settings can't blend). + /// + /// Derived from `position`, so any view that reads this is pulled into the 20 Hz tick + /// loop. UI should read `transitionCountdownSeconds` instead. public var secondsUntilTransition: TimeInterval? { guard !isTransitioning, nextIndex != nil else { return nil } let dur = effectiveEndSeconds @@ -426,6 +429,23 @@ public final class Player { return max(0, (dur - fade) - position) } + /// Whole-second mirror of `secondsUntilTransition` for the Now Playing countdown, written + /// only when the displayed second actually changes. + /// + /// The countdown is the only thing on that panel that moves while a blend is merely + /// *scheduled*, and reading `secondsUntilTransition` for it dragged the whole transition + /// visualization — a `TransitionPreview` rebuild, two `Canvas` redraws, and a read of both + /// tracks' `beatTimes` arrays out of SwiftData — into every one of the 20 ticks a second. + /// Publishing it at 1 Hz keeps that panel off the tick path entirely. + public internal(set) var transitionCountdownSeconds: Int? + + /// Republishes the coarse countdown. Called from `tick()` and from `persistState()` (the + /// funnel every playback discontinuity already goes through), so it can never go stale. + func refreshTransitionCountdown() { + let seconds = secondsUntilTransition.map { Int($0.rounded()) } + if transitionCountdownSeconds != seconds { transitionCountdownSeconds = seconds } + } + private func tick() { guard isPlaying, let audio else { return } // Configuration changes can report the engine as running in their callback, then stop it @@ -526,6 +546,8 @@ public final class Player { pushHistory(currentTrack) stopPlayback() } + // Last thing in the tick, so it reflects any transition this tick started or finished. + refreshTransitionCountdown() } } From 758ee8735f33f4757b5d85982713f2789a72e0c6 Mon Sep 17 00:00:00 2001 From: sanylax Date: Thu, 10 Sep 2026 23:13:10 +0000 Subject: [PATCH 2/7] Speed up the keyboard autocorrect, loudness pass and blend clock reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CPU paths, all verified against the previous implementations rather than eyeballed. `CatalogAutocorrect` runs a bounded Levenshtein against the entire vocabulary on every keystroke, and with the user's library seeded in that is thousands of words. Each candidate used to allocate two scalar arrays and two matrix rows; words now carry their scalars and length from the moment they're learned, and the matrix rows are allocated once per query and reused. A differential run over 5,520 fuzzed queries (three limits each, plus corrections) produces identical output, at roughly a third of the time. `LoudnessMeter.integratedLUFS` ran three integer divisions per sample — tens of millions per track — to wrap the ring buffer, index the sum and test the block boundary. The write cursor now wraps by hand, block boundaries are counted, and each block's sum walks the ring as two contiguous runs, visiting the same elements in the same order. Results are bit-identical on 31 s, 95 s, 7 s and 360 s signals at 44.1 and 48 kHz; a six-minute track measures ~35% faster. `Player.displayProgress` called `Deck.elapsed` on every read, which round-trips through the engine for a render timestamp and a player time. Three progress views read it per tick during a blend; they now share the tick's snapshot, which is the same value they were each recomputing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- .../ContinuityCore/CatalogAutocorrect.swift | 91 +++++++++++++++---- .../ContinuityCore/LoudnessMeter.swift | 32 +++++-- .../Sources/Playback/Player+Transitions.swift | 2 + .../Sources/Playback/Player.swift | 11 ++- 4 files changed, 111 insertions(+), 25 deletions(-) diff --git a/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift b/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift index c9b81fc..1a00168 100644 --- a/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift +++ b/Packages/ContinuityCore/Sources/ContinuityCore/CatalogAutocorrect.swift @@ -11,16 +11,33 @@ import Foundation /// - `correction(for:)` returns a replacement only when it is *confidently* wrong: the typed /// word is unknown and exactly one edit away from a known word — applied on space, like a /// conventional keyboard autocorrect, but against the music vocabulary. +/// +/// Both run a bounded Levenshtein against the WHOLE vocabulary, and `suggestions` runs on every +/// keystroke — with the user's library seeded in, that vocabulary is thousands of words. So the +/// per-word cost is kept allocation-free: each word's unicode scalars and character count are +/// derived once, when it's learned, and the distance matrix rows are allocated once per query +/// and reused across every candidate. public struct CatalogAutocorrect: Sendable { - /// Learned vocabulary: lowercase word → accumulated weight (frequency across learned - /// phrases, so words from many catalog hits outrank one-off matches). - private var vocabulary: [String: Int] = [:] + /// One learned word: the text, the scalars and length the matcher needs (precomputed), and + /// its accumulated weight (frequency across learned phrases, so words from many catalog + /// hits outrank one-off matches). + private struct Entry: Sendable { + let word: String + let scalars: [Unicode.Scalar] + let characterCount: Int + var weight: Int + } + + /// Learned vocabulary, in first-seen order. + private var entries: [Entry] = [] + /// Position of each word in `entries`, so re-learning a known word is a weight bump. + private var indexByWord: [String: Int] = [:] public init() {} /// Number of distinct words learned so far. - public var wordCount: Int { vocabulary.count } + public var wordCount: Int { entries.count } // MARK: Learning @@ -29,7 +46,17 @@ public struct CatalogAutocorrect: Sendable { public mutating func learn(phrases: [String], weight: Int = 1) { for phrase in phrases { for word in Self.words(in: phrase) { - vocabulary[word, default: 0] += weight + if let index = indexByWord[word] { + entries[index].weight += weight + } else { + indexByWord[word] = entries.count + entries.append(Entry( + word: word, + scalars: Array(word.unicodeScalars), + characterCount: word.count, + weight: weight + )) + } } } } @@ -60,14 +87,19 @@ public struct CatalogAutocorrect: Sendable { let typed = partial.lowercased() guard typed.count >= 2, limit > 0 else { return [] } + let typedScalars = Array(typed.unicodeScalars) + var previous = [Int](repeating: 0, count: typedScalars.count + 1) + var current = previous + var scored: [(word: String, tier: Int, weight: Int)] = [] let maxDistance = typed.count <= 4 ? 1 : 2 - for (word, weight) in vocabulary where word != typed { - if word.hasPrefix(typed) { - scored.append((word, 0, weight)) - } else if abs(word.count - typed.count) <= maxDistance, - Self.editDistance(word, typed, limit: maxDistance) <= maxDistance { - scored.append((word, 1, weight)) + for entry in entries where entry.word != typed { + if entry.word.hasPrefix(typed) { + scored.append((entry.word, 0, entry.weight)) + } else if abs(entry.characterCount - typed.count) <= maxDistance, + Self.editDistance(entry.scalars, typedScalars, limit: maxDistance, + previous: &previous, current: ¤t) <= maxDistance { + scored.append((entry.word, 1, entry.weight)) } } return scored @@ -81,11 +113,25 @@ public struct CatalogAutocorrect: Sendable { /// known words (however weird; this is music) must never be "corrected". public func correction(for word: String) -> String? { let typed = word.lowercased() - guard typed.count >= 3, vocabulary[typed] == nil else { return nil } - return vocabulary - .filter { abs($0.key.count - typed.count) <= 1 && Self.editDistance($0.key, typed, limit: 1) <= 1 } - .max { ($0.value, $1.key) < ($1.value, $0.key) }? // heaviest wins; ties break alphabetically - .key + guard typed.count >= 3, indexByWord[typed] == nil else { return nil } + + let typedScalars = Array(typed.unicodeScalars) + var previous = [Int](repeating: 0, count: typedScalars.count + 1) + var current = previous + + // Heaviest wins; ties break alphabetically. + var best: (word: String, weight: Int)? + for entry in entries where abs(entry.characterCount - typed.count) <= 1 { + guard Self.editDistance(entry.scalars, typedScalars, limit: 1, + previous: &previous, current: ¤t) <= 1 else { continue } + if let incumbent = best, + entry.weight < incumbent.weight + || (entry.weight == incumbent.weight && entry.word > incumbent.word) { + continue + } + best = (entry.word, entry.weight) + } + return best?.word } // MARK: Edit distance @@ -94,9 +140,18 @@ public struct CatalogAutocorrect: Sendable { /// distance provably exceeds `limit` (the callers only care about "≤ limit"). static func editDistance(_ a: String, _ b: String, limit: Int) -> Int { let s = Array(a.unicodeScalars), t = Array(b.unicodeScalars) + var previous = [Int](repeating: 0, count: t.count + 1) + var current = previous + return editDistance(s, t, limit: limit, previous: &previous, current: ¤t) + } + + /// Scanning variant: takes pre-derived scalars and caller-owned matrix rows, so a scan over + /// thousands of vocabulary words allocates nothing per word. `previous` and `current` must + /// each have at least `t.count + 1` elements; their contents on entry are irrelevant. + static func editDistance(_ s: [Unicode.Scalar], _ t: [Unicode.Scalar], limit: Int, + previous: inout [Int], current: inout [Int]) -> Int { if abs(s.count - t.count) > limit { return limit + 1 } - var previous = Array(0...t.count) - var current = [Int](repeating: 0, count: t.count + 1) + for j in 0...t.count { previous[j] = j } for i in 1...max(s.count, 1) where !s.isEmpty { current[0] = i var rowMin = i diff --git a/Packages/ContinuityCore/Sources/ContinuityCore/LoudnessMeter.swift b/Packages/ContinuityCore/Sources/ContinuityCore/LoudnessMeter.swift index a56ab8b..c18765d 100644 --- a/Packages/ContinuityCore/Sources/ContinuityCore/LoudnessMeter.swift +++ b/Packages/ContinuityCore/Sources/ContinuityCore/LoudnessMeter.swift @@ -33,18 +33,38 @@ public enum LoudnessMeter { // while analysis runs during playback). Each block's mean-square is summed from the // ring in logical order, so the arithmetic — order included — matches the old // whole-buffer implementation exactly. + // + // The index arithmetic is division-free. The obvious form of this loop (`ring[i % block]` + // to write, `ring[j % block]` to sum, `(filled - block) % hop` to decide) runs three + // integer divisions per sample — tens of millions per track, and integer division is + // the slowest thing in the loop by a wide margin. The write cursor wraps by hand, the + // next block boundary is counted rather than tested for divisibility, and the sum walks + // the ring as two contiguous runs (`base..= block, (filled - block) % hop == 0 else { continue } - let start = filled - block + guard filled == nextBlockEnd else { continue } + nextBlockEnd += hop + let base = (filled - block) % block var sum = 0.0 - for j in start.. 0 ? audio.idle.loadedDuration : incoming.durationSeconds guard incomingDuration > 0 else { return current } - let incomingPosition = incomingStartOffset + audio.idle.elapsed + // The tick's snapshot, not a fresh `Deck.elapsed`: every reader of this property is a + // 20 Hz leaf, and each `elapsed` call round-trips through AVAudioEngine for a render + // timestamp and a player time. Three progress views during a blend meant six of those + // per tick for a value that only moves once per tick anyway. + let incomingPosition = incomingStartOffset + incomingDeckElapsed let incomingFraction = min(max(incomingPosition / incomingDuration, 0), 1) let weight = min(max(transitionProgress, 0), 1) return current + (incomingFraction - current) * weight @@ -126,6 +130,10 @@ public final class Player { /// Seconds the incoming deck was seeked into its track for beat alignment — becomes the /// promoted deck's clock baseline when the transition completes. var incomingStartOffset: TimeInterval = 0 + /// The incoming deck's elapsed time as of the current tick, so the 20 Hz progress leaves + /// share one reading instead of each querying the engine's render clock. Bookkeeping only — + /// the views that read `displayProgress` are already invalidated by `position`. + @ObservationIgnored var incomingDeckElapsed: TimeInterval = 0 /// Low-shelf cut (dB) applied to the incoming deck's low end at the start of a bass-swap blend, /// ramped back to flat over the first part of the transition. let bassSwapCutDB: Float = -9 @@ -490,6 +498,7 @@ public final class Player { // Drive the blend off the INCOMING deck's clock — it keeps advancing even after the // outgoing file drains, so the transition can never get stuck half-faded. let incomingElapsed = audio.idle.elapsed + incomingDeckElapsed = incomingElapsed let gains = plan.gains(position: incomingElapsed, startPosition: 0) audio.current.volume = Float(gains.outgoing) audio.idle.volume = Float(gains.incoming) From d8dfbb79e741e7d3358f442d8b08b65bee2ab47d Mon Sep 17 00:00:00 2001 From: sanylax Date: Thu, 10 Sep 2026 23:18:04 +0000 Subject: [PATCH 3/7] Bound lock-screen artwork, fix stale image assignments, record the invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared artwork and backdrop loads deliberately outlive any one view's task so several views can share one fetch — which means view cancellation no longer reaches them. Without an explicit check, a slow load for the previous track could land after the next track's cached one and repaint in the wrong song's colors, or hand a recycled row the image its previous track asked for. Each call site now drops a result it no longer wants. `NowPlayingBridge` kept whatever the artwork decoded to for as long as the track played — routinely 1400 px square, about 7.8 MB resident, for a lock screen thumbnail. It now decodes bounded through ImageIO and reads local files directly instead of through the URL loading system. A failed load also used to latch `artworkURL` at the track's URL, so every later update took the "already have it" branch and the lock screen stayed blank for the rest of the song; clearing it lets the next update retry. CLAUDE.md gains a "Performance invariants" section so the tick-path, artwork-cache, batching and Liquid Glass rules survive future sessions, and the known-issues list is corrected: what this pass fixed is gone, what it measured and deliberately left (the per-window memmove, `Deck.load`'s file opens, the demo tone synth) is written down with the reasoning. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- App/Continuity/Views/MainPagerView.swift | 7 ++- App/Continuity/Views/Theme.swift | 14 ++++- CLAUDE.md | 62 +++++++++++++++---- .../Sources/Playback/NowPlayingBridge.swift | 42 ++++++++++++- 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/App/Continuity/Views/MainPagerView.swift b/App/Continuity/Views/MainPagerView.swift index 79f2211..ff4ab30 100644 --- a/App/Continuity/Views/MainPagerView.swift +++ b/App/Continuity/Views/MainPagerView.swift @@ -162,7 +162,12 @@ private struct PagerBackdrop: View { .frame(height: pageHeight * 3, alignment: .top) .task(id: url) { guard let url else { style = nil; return } - style = await BackdropRenderer.style(for: url) + // Shared with `AlbumBackdrop` (which this view hosts), so one render serves both. + // It outlives either view's task, hence the explicit cancellation check before + // adopting a result that may now be for the previous track. + let resolved = await BackdropRenderer.style(for: url) + guard !Task.isCancelled else { return } + style = resolved } } } diff --git a/App/Continuity/Views/Theme.swift b/App/Continuity/Views/Theme.swift index a6964cb..5f7cec8 100644 --- a/App/Continuity/Views/Theme.swift +++ b/App/Continuity/Views/Theme.swift @@ -220,7 +220,13 @@ struct AlbumBackdrop: View { .ignoresSafeArea() .task(id: url) { guard let url else { style = nil; return } - style = await BackdropRenderer.style(for: url) + let resolved = await BackdropRenderer.style(for: url) + // The render is shared between backdrops and deliberately outlives any one view's + // task, so cancellation doesn't reach it — check here instead. Without this, a slow + // fetch for the previous track can land after the next track's (cached, instant) + // one and repaint the screen in the wrong song's colors. + guard !Task.isCancelled else { return } + style = resolved } } } @@ -449,7 +455,11 @@ struct CachedArtworkImage: View { loaded = hit return } - loaded = await ArtworkImageStore.shared.image(for: url) + let image = await ArtworkImageStore.shared.image(for: url) + // Loads are shared across every view asking for this URL, so they outlive this + // view's task; a recycled row must not adopt the image its previous track asked for. + guard !Task.isCancelled else { return } + loaded = image } } } diff --git a/CLAUDE.md b/CLAUDE.md index 71eb7a5..0c9d70f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,18 +101,56 @@ playlist, `Track.stemKey = youtubeVideoID ?? id.uuidString`). Remaining v1 items unless asked): `PrivacyInfo.xcprivacy`, ASC metadata (privacy policy/support URLs, screenshots), accessibility-label pass, `DEVELOPMENT_TEAM` removal from project.yml. -## Known issues (noted during the OOM audit, deliberately not fixed) - -1. `LoudnessMeter.integratedLUFS` allocates a full-length `[Double]` buffer (~127 MB / 6 min). -2. `StreamingStereoDecoder` treats mono sources as dual-mono aliases. -3. `runStreaming` uses O(n) `removeFirst(n)` per window (CPU churn, memory fine). -4. `separateStems` pins `@Model track` + `ModelContext` across minutes-long tasks. -5. `ensureStems` spawns overlapping `enforceBudget` passes on rapid skips. -6. Manual `syncAll` bypasses auto-sync's failure backoff. -7. `onQueueExhausted`/`restorePlaybackSession` fetch every Track incl. `beatTimes`. -8. `SearchResultsView` recomputes matches per keystroke without memoization. -9. `AudioStack.init` force-unwraps `AVAudioFormat(...)`. -10. `NowPlayingBridge` can briefly blank lock-screen artwork when a fetch is superseded. +## Performance invariants (from the perf + Liquid Glass audit) + +Each of these was a measured cost, not a style preference. Don't undo them. + +- **Nothing on the 20 Hz tick path but leaves.** `position`, `transitionProgress` and anything + derived from them (`secondsUntilTransition`, `displayProgress`) may only be read by tiny leaf + views: `TrackProgressRing`, `ScrubberBar`, `MiniProgressLine`, `BlendPlayhead`, + `TransitionCountdownPill`. The Now Playing transition panel used to read the countdown, so + every tick re-ran `TransitionPreview.make`, re-read both tracks' `beatTimes` out of SwiftData + and redrew two Canvases. `Player.transitionCountdownSeconds` publishes the countdown at 1 Hz + for exactly this reason; `TransitionVisualizationView` resolves beat positions and curve + samples once in `init`. +- **A row's now-playing highlight is read by the row, never passed in.** As a parameter it makes + every track change invalidate the list's parent, which re-sorts the whole playlist. +- **Cache directories are `static let`.** `AudioCache`/`StemCache`/`ArtworkStore.directory` are + read per artwork URL, per row, per frame; as computed properties each read ran + `createDirectory` + `setResourceValues`. +- **All artwork goes through `ArtworkImageStore`** (shared bounded decode cache + ImageIO + downsampling), never `AsyncImage`, which caches nothing and re-decodes per row. Backdrop + renders are coalesced per URL and share one `CIContext`; results are NSCache-bounded, so they + can't accumulate the way the old dictionary did. Any load shared this way outlives a single + view's task, so check `Task.isCancelled` before assigning the result. +- **Bulk filesystem work is batched and off the main actor**: one directory listing per cache + (`CacheIndex`) instead of per-track `fileExists` probes, cleanup sweeps in detached tasks, and + stem-cache budget passes coalesced (`scheduleBudgetPass`) rather than one full scan per skip. +- **Launch fetches that only need ids use `propertiesToFetch`** — a plain `FetchDescriptor` + hydrates every row's `beatTimes`. +- **Liquid Glass is the real API, everywhere.** `glassEffect` via `continuityGlass` / + `continuityGlassCapsule` — no `Material` stand-ins, no hand-drawn hairlines. Groups of glass + elements (the keyboard's key grid, the transition chips) sit in a `GlassEffectContainer` with + `spacing: 0` so they composite in one pass without merging into blobs, and glass is never + layered directly on glass (the keyboard plane stays an opaque fill). +- `CatalogAutocorrect` and `LoudnessMeter` are allocation-/division-light on purpose; both have + differential checks behind them (identical suggestions over thousands of fuzzed queries; + bit-identical LUFS). Re-verify the same way before changing their loops. + +## Known issues (noted during the audits, deliberately not fixed) + +1. `StreamingStereoDecoder` treats mono sources as dual-mono aliases. +2. `runStreaming` uses O(n) `removeFirst(n)` per window — ~344 KB memmove per window against a + transformer inference, so it stays measurement noise. Memory fine. +3. `separateStems` pins `@Model track` + `ModelContext` across minutes-long tasks. +4. Manual `syncAll` bypasses auto-sync's failure backoff (deliberate — it means "now"). +5. `SearchResultsView` still rescans on each keystroke; it no longer sorts, and no longer reruns + on track changes, so the remaining cost is one `localizedCaseInsensitiveContains` pass. +6. `AudioStack.init` force-unwraps `AVAudioFormat(...)` — cannot fail for 44.1 kHz stereo. +7. `Deck.load` opens `AVAudioFile`s on the main actor at every track change and blend start + (a few ms); moving it off would have to keep node scheduling ordered. +8. `ToneSynth.makeLoop` synthesizes ~1M `sin` calls per demo-track load. Caching the buffers + would cost 2.8 MB each — the wrong trade for this app; demo tracks only. ## Debugging on device (the owner can run these) diff --git a/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift b/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift index 192b9a0..eda4817 100644 --- a/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift +++ b/Packages/ContinuityKit/Sources/Playback/NowPlayingBridge.swift @@ -1,5 +1,6 @@ import Foundation import Domain +import ImageIO import MediaPlayer import UIKit @@ -92,10 +93,16 @@ final class NowPlayingBridge { // 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 image = await Self.loadArtwork(url) + guard !Task.isCancelled, let self, self.artworkURL == url else { return } + guard let image else { + // A failed load used to leave `artworkURL` latched at this track's URL, so the + // periodic updates all took the "already have it" branch and the lock screen + // stayed blank for the rest of the song. Clearing it lets the next update retry. + self.artworkURL = nil + return + } let art = MPMediaItemArtwork(boundsSize: image.size) { _ in image } - guard let self, self.artworkURL == url else { return } self.artwork = art // Merge rather than rebuild — playback state may have moved on since the fetch began. var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] @@ -104,6 +111,35 @@ final class NowPlayingBridge { } } + /// Reads and decodes one artwork URL at a bounded size, off the main actor. + /// + /// Bounded because this image is retained for as long as the track is playing: artwork + /// extracted from an imported file is routinely 1400 px square, which is ~7.8 MB of + /// resident bitmap to feed a lock-screen thumbnail. Local files are read directly rather + /// than pushed through the URL loading system. + private nonisolated static func loadArtwork(_ url: URL) async -> UIImage? { + let data: Data? + if url.isFileURL { + data = try? Data(contentsOf: url, options: .mappedIfSafe) + } else { + data = try? await URLSession.shared.data(from: url).0 + } + guard let data else { return nil } + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return UIImage(data: data) + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: 600, + ] + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return UIImage(data: data) + } + return UIImage(cgImage: thumbnail) + } + /// Runs a Player call on the main actor from a (possibly off-main) command callback. private nonisolated func onMain(_ action: @escaping @MainActor (Player) -> Void) { Task { @MainActor [weak self] in From 3747ef9a1921969a234257cbca84edd26526b52f Mon Sep 17 00:00:00 2001 From: sanylax Date: Thu, 10 Sep 2026 23:21:42 +0000 Subject: [PATCH 4/7] Take the import paths off the main actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Importing a folder of local files did all of its heavy work on the main actor: a recursive directory enumeration with per-file resource reads, then a multi-megabyte `copyItem` and an artwork write per song. A real music folder is thousands of syscalls and gigabytes of copying, so the import spinner had no frames to animate in. Enumeration, copy and artwork write now run off it. The duplicate check rescanned the growing "Local Files" playlist for every file imported — three SwiftData property reads per comparison, quadratic over the import. The caller snapshots the playlist once as plain values and carries it forward, with the same title/artist/±1s semantics. It's a lookup now rather than find-or-create, so pointing the picker at a folder with no music in it no longer leaves an empty playlist behind. Bulk enqueues (playlist import, catalog album, Apple Music, sync, retry-all, the launch resume pass) saved the model context once per track — for a 500-track YouTube playlist, 500 synchronous saves in one loop, each costing more than the last. `enqueue` takes `saving:` so those callers save once at the end; single-track callers are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- .../Ingest/PreparationQueue+AppleMusic.swift | 2 +- .../Ingest/PreparationQueue+Catalog.swift | 3 +- .../Ingest/PreparationQueue+Import.swift | 4 +- .../Ingest/PreparationQueue+LocalImport.swift | 88 ++++++++++++++----- .../Ingest/PreparationQueue+Sync.swift | 4 +- .../Sources/Ingest/PreparationQueue.swift | 23 +++-- 6 files changed, 90 insertions(+), 34 deletions(-) diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift index 7f35361..c7d6b89 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+AppleMusic.swift @@ -67,7 +67,7 @@ extension PreparationQueue { ) playlist.tracks.append(track) context.insert(track) - enqueue(track, in: context) + enqueue(track, in: context, saving: false) } playlist.touch() // creation + initial tracks count as a content change try? context.save() diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift index b99b21c..d14c3d3 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Catalog.swift @@ -57,7 +57,8 @@ extension PreparationQueue { ) playlist.tracks.append(track) context.insert(track) - enqueue(track, in: context) + // Saved once after the loop (see `enqueue(_:in:saving:)`). + enqueue(track, in: context, saving: false) } playlist.touch() // creation + initial tracks count as a content change try? context.save() diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift index 387f127..0ab2d66 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Import.swift @@ -45,7 +45,7 @@ extension PreparationQueue { ) playlist.tracks.append(track) context.insert(track) - enqueue(track, in: context) + enqueue(track, in: context, saving: false) } playlist.touch() // creation + initial tracks count as a content change try? context.save() @@ -90,7 +90,7 @@ extension PreparationQueue { ) playlist.tracks.append(track) context.insert(track) - enqueue(track, in: context) + enqueue(track, in: context, saving: false) } playlist.touch() // creation + initial tracks count as a content change try? context.save() diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift index 091de59..796c5df 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift @@ -22,25 +22,49 @@ extension PreparationQueue { /// many tracks were imported; failures are logged and skipped (partial imports succeed). public func importLocalFiles(_ urls: [URL], in context: ModelContext) async -> Int { var imported = 0 + // What "Local Files" already holds, snapshotted once as plain values. The duplicate + // check used to re-scan the playlist's SwiftData rows for every file imported — three + // managed-property reads per comparison, quadratic over a folder import. + var existing = (Self.existingLocalFilesPlaylist(in: context)?.tracks ?? []).map { + ImportedSong(title: $0.title, artist: $0.artist, duration: $0.durationSeconds) + } for url in urls { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } if (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { // Security scope covers the folder's descendants, so files found by the scan - // are readable without their own scoped access. - for file in Self.scanForMusic(in: url) { - if await importOne(file, in: context) { imported += 1 } + // are readable without their own scoped access. The scan itself is a recursive + // enumeration with per-file resource reads — thousands of syscalls for a real + // music folder — so it runs off the main actor, or the import spinner freezes + // along with the rest of the UI. + let files = await Task.detached(priority: .userInitiated) { + Self.scanForMusic(in: url) + }.value + for file in files { + if let song = await importOne(file, in: context, existing: existing) { + existing.append(song) + imported += 1 + } } - } else if await importOne(url, in: context) { + } else if let song = await importOne(url, in: context, existing: existing) { + existing.append(song) imported += 1 } } return imported } + /// The identity the duplicate check compares — kept as plain values so a folder import + /// doesn't read the growing playlist out of SwiftData once per file. + struct ImportedSong { + let title: String + let artist: String + let duration: Double + } + /// Recursively lists the music files in a folder: audio extension, not hidden, and large /// enough to plausibly be a song. Sorted by path so import order (→ `sortIndex`) is stable. - private static func scanForMusic(in folder: URL) -> [URL] { + private nonisolated static func scanForMusic(in folder: URL) -> [URL] { guard let enumerator = FileManager.default.enumerator( at: folder, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], @@ -58,7 +82,10 @@ extension PreparationQueue { return files.sorted { $0.path < $1.path } } - private func importOne(_ url: URL, in context: ModelContext) async -> Bool { + /// Imports one file, returning its identity on success (nil when it failed or was a + /// duplicate of something in `existing`). + private func importOne(_ url: URL, in context: ModelContext, + existing: [ImportedSong]) async -> ImportedSong? { // Direct file picks carry their own security scope; files inside a scanned folder // are covered by the folder's scope (startAccessing then returns false — harmless). let accessing = url.startAccessingSecurityScopedResource() @@ -70,12 +97,18 @@ extension PreparationQueue { let trackID = UUID() let ext = url.pathExtension.isEmpty ? "m4a" : url.pathExtension.lowercased() let destination = AudioCache.fileURL(videoID: trackID.uuidString, container: ext) - do { - try FileManager.default.copyItem(at: url, to: destination) - } catch { - Logger.ingest.error("local import copy failed for \(url.lastPathComponent, privacy: .public): \(String(describing: error), privacy: .public)") - return false - } + // Off the main actor: songs are multi-megabyte files, and a folder import copies + // hundreds of them back to back. + let copied = await Task.detached(priority: .userInitiated) { () -> Bool in + do { + try FileManager.default.copyItem(at: url, to: destination) + return true + } catch { + Logger.ingest.error("local import copy failed for \(url.lastPathComponent, privacy: .public): \(String(describing: error), privacy: .public)") + return false + } + }.value + guard copied else { return nil } // Metadata load runs off-main (AVURLAsset touches the file). let fallbackTitle = url.deletingPathExtension().lastPathComponent @@ -106,12 +139,13 @@ extension PreparationQueue { let artist = meta.artist?.isEmpty == false ? meta.artist! : "Unknown Artist" // Re-scanning the same folder must not double-import: same title + artist + length - // (±1s) already in Local Files means we've seen this song. - if playlist.tracks.contains(where: { - $0.title == title && $0.artist == artist && abs($0.durationSeconds - meta.duration) < 1 + // (±1s) already in Local Files means we've seen this song. Compared against the + // caller's snapshot, so this stays plain-value work as the playlist grows. + if existing.contains(where: { + $0.title == title && $0.artist == artist && abs($0.duration - meta.duration) < 1 }) { try? FileManager.default.removeItem(at: destination) - return false + return nil } // Embedded artwork → Application Support/Artwork/.jpg (excluded from backup by @@ -120,7 +154,11 @@ extension PreparationQueue { if let data = meta.artwork { let name = "\(trackID.uuidString).jpg" let artworkURL = ArtworkStore.directory.appendingPathComponent(name) - if (try? data.write(to: artworkURL)) != nil { artworkPath = name } + // Cover art runs to megabytes; write it off the main actor like the audio copy. + let wrote = await Task.detached(priority: .userInitiated) { + (try? data.write(to: artworkURL)) != nil + }.value + if wrote { artworkPath = name } } let track = Track( @@ -144,17 +182,25 @@ extension PreparationQueue { // BPM/key/loudness/silence analysis runs post-ready, limiter-gated. backfillTrackDetails(track, in: context) - return true + return ImportedSong(title: title, artist: artist, duration: meta.duration) } - /// Returns the shared "Local Files" playlist for local imports, creating it if missing. - private static func findOrCreateLocalFilesPlaylist(in context: ModelContext) -> Playlist { + /// The shared "Local Files" playlist, if one exists. Lookup only: the dedupe snapshot is + /// taken before anything is known to import, and picking a folder with no music in it must + /// not leave an empty playlist behind. + private static func existingLocalFilesPlaylist(in context: ModelContext) -> Playlist? { let title = "Local Files" let descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) // Skip a demo playlist that happens to share the name — imports go to a real one. - if let existing = (try? context.fetch(descriptor))?.first(where: { !$0.isDemo }) { + return (try? context.fetch(descriptor))?.first(where: { !$0.isDemo }) + } + + /// Returns the shared "Local Files" playlist for local imports, creating it if missing. + private static func findOrCreateLocalFilesPlaylist(in context: ModelContext) -> Playlist { + if let existing = existingLocalFilesPlaylist(in: context) { return existing } + let title = "Local Files" let playlist = Playlist( title: title, subtitle: "Imported from Files", diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift index b23852c..f716731 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift @@ -133,7 +133,7 @@ extension PreparationQueue { ) playlist.tracks.append(track) context.insert(track) - enqueue(track, in: context) + enqueue(track, in: context, saving: false) changed = true } } @@ -190,7 +190,7 @@ extension PreparationQueue { ) playlist.tracks.append(track) context.insert(track) - enqueue(track, in: context) + enqueue(track, in: context, saving: false) changed = true } } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift index bb0bfac..7b91ba3 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -76,17 +76,21 @@ public final class PreparationQueue { /// /// User-initiated calls (tapping a failed row) reset the track's backoff — an explicit retry /// means "try now", not "resume the curve where it left off". - public func enqueue(_ track: Track, in context: ModelContext) { + /// + /// Bulk callers — playlist import, sync, the launch resume pass — pass `saving: false` and + /// save once when they're finished: a `save()` per track froze the main actor for the whole + /// length of a 500-track import, and each save costs more as the object graph grows. + public func enqueue(_ track: Track, in context: ModelContext, saving: Bool = true) { ingestAttempts[track.id] = nil retryScheduledTrackIDs.remove(track.id) - enqueueInternal(track, in: context) + enqueueInternal(track, in: context, saving: saving) } /// Enqueue without clearing the retry budget — used by the automatic backoff so a track that /// keeps failing keeps climbing its curve instead of looping at `base` forever. - func enqueueInternal(_ track: Track, in context: ModelContext) { + func enqueueInternal(_ track: Track, in context: ModelContext, saving: Bool = true) { track.prepState = .pending - try? context.save() + if saving { try? context.save() } Task { await process(track, in: context) } } @@ -100,9 +104,12 @@ public final class PreparationQueue { } else { candidates = (try? context.fetch(FetchDescriptor())) ?? [] } + var enqueued = false for track in candidates where track.prepState == .failed && !track.isDemo { - enqueue(track, in: context) + enqueue(track, in: context, saving: false) + enqueued = true } + if enqueued { try? context.save() } } /// Resumes preparation for a persisted library at launch: re-enqueues tracks that were @@ -132,7 +139,8 @@ public final class PreparationQueue { case .ready: let hasAudio = track.localRelativePath.map { cacheIndex.hasAudio($0) } ?? false if !hasAudio { - enqueue(track, in: context) // file lost/evicted → re-fetch end to end + enqueue(track, in: context, saving: false) // file lost/evicted → re-fetch end to end + needsSave = true } else { // Stems are demand-driven from the play queue (`ensureStems`) — never // separated library-wide at launch. Just true-up links vs the disk. @@ -140,7 +148,8 @@ public final class PreparationQueue { backfillTrackDetails(track, in: context) } case .pending, .preparing: - enqueue(track, in: context) // interrupted before finishing → pick back up + enqueue(track, in: context, saving: false) // interrupted before finishing → pick back up + needsSave = true case .failed: break } From 4a80d143484aadd2c4ba9edfe5f88ee73690cbb9 Mon Sep 17 00:00:00 2001 From: sanylax Date: Thu, 10 Sep 2026 23:24:17 +0000 Subject: [PATCH 5/7] Run the backdrop render entirely off the main actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fetchBestArtwork` was main-actor isolated, so the ImageIO decode of a 1280x720 maxres thumbnail happened on the main thread before the palette and gaussian work hopped off it. The whole render — fetch, decode, area average, blur — is now one detached task, which is also what makes it correct to share between the two backdrops that request it. Also trims the artwork cache's byte ceiling to 32 MB. This app gates stem separation on 1.4 GB of headroom; a decoded-image cache has no business being a visible fraction of that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- App/Continuity/Views/Theme.swift | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/App/Continuity/Views/Theme.swift b/App/Continuity/Views/Theme.swift index 5f7cec8..febb426 100644 --- a/App/Continuity/Views/Theme.swift +++ b/App/Continuity/Views/Theme.swift @@ -122,7 +122,10 @@ final class ArtworkImageStore { private let cache: NSCache = { let cache = NSCache() cache.countLimit = 180 - cache.totalCostLimit = 48 * 1024 * 1024 + // ~45 YouTube thumbnails' worth of decoded bitmap. Deliberately modest: this app has a + // jetsam history and gates stem separation on 1.4 GB of headroom, so the artwork cache + // must stay a rounding error against that. NSCache also drops it under memory pressure. + cache.totalCostLimit = 32 * 1024 * 1024 return cache }() @@ -143,11 +146,11 @@ final class ArtworkImageStore { if let existing = inFlight[url] { return await existing.value } let maxPixel = Self.maxPixelSize - let task = Task { () -> UIImage? in - await Task.detached(priority: .userInitiated) { () -> UIImage? in - guard let data = await ArtworkDecoder.data(for: url) else { return nil } - return ArtworkDecoder.image(from: data, maxPixel: maxPixel) - }.value + // Detached: fetching and decoding must not run on the main actor, and this load is + // shared, so it must not inherit (or be cancelled with) whichever view asked first. + let task = Task.detached(priority: .userInitiated) { () -> UIImage? in + guard let data = await ArtworkDecoder.data(for: url) else { return nil } + return ArtworkDecoder.image(from: data, maxPixel: maxPixel) } inFlight[url] = task let image = await task.value @@ -270,12 +273,13 @@ enum BackdropRenderer { if let hit = cache.object(forKey: url as NSURL) { return hit.style } if let existing = inFlight[url] { return await existing.value } - let task = Task { () -> BackdropStyle? in - guard let source = await fetchBestArtwork(url) else { return nil } - return await Task.detached(priority: .userInitiated) { () -> BackdropStyle? in - guard let colors = samplePalette(source) else { return nil } - return BackdropStyle(colors: colors, blurredArt: blurredRender(source)) - }.value + // One detached task for the whole render: fetch, decode, palette sample and gaussian + // all belong off the main actor, and this work is shared between backdrops, so it must + // not be cancelled along with whichever view happened to ask for it first. + let task = Task.detached(priority: .userInitiated) { () -> BackdropStyle? in + guard let source = await Self.fetchBestArtwork(url) else { return nil } + guard let colors = Self.samplePalette(source) else { return nil } + return BackdropStyle(colors: colors, blurredArt: Self.blurredRender(source)) } inFlight[url] = task let style = await task.value @@ -315,7 +319,7 @@ enum BackdropRenderer { /// YouTube thumbnails come in quality tiers; try the sharper variants first (they 404 for /// some videos), falling back to the stored URL. - private static func fetchBestArtwork(_ url: URL) async -> UIImage? { + private nonisolated static func fetchBestArtwork(_ url: URL) async -> UIImage? { var candidates: [URL] = [] let raw = url.absoluteString if raw.contains("/hqdefault") { From f2318468e979d4aa8e2772f0285ad2bd31d17c7d Mon Sep 17 00:00:00 2001 From: sanylax Date: Thu, 10 Sep 2026 23:36:14 +0000 Subject: [PATCH 6/7] Fix the moving-phone crash class and the pager's black bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crashes are not a sensor. The app uses no motion, altimeter or location APIs at all, and is portrait-only — movement is a proxy for audio route churn (AirPods connecting and dropping, car stereos, a jostled cable) and for network path changes. Around a route change or an engine configuration change, AVAudioPlayerNode's time base can report an unset sample rate, and `sampleTime / 0` is `.infinity` in Swift rather than an error. That escaped `Deck.elapsed` into `Player.position`, and from there into two things that trap rather than degrade: `AVAudioFramePosition(seconds * sampleRate)`, which traps on a non-finite double (and `scheduleSegment` raises on a negative start frame), and SwiftUI layout, `Shape.trim` and `Slider`, which trap on one too. A negative clock baseline reached the same conversion. So: `Deck.elapsed` now requires a valid sample time and a positive sample rate and reads 0 otherwise; `scheduleSegment` clamps into the file before converting; `Player.duration` and every published progress fraction go through a clamp that treats non-finite as zero, because `min`/`max` do not sanitize NaN — `min(NaN, 1)` is NaN; `seek` ignores a non-finite target instead of reinterpreting it as zero; the countdown clamps before `Int(_:)`. `PlayerClockSafetyTests` poisons the clock directly and pins the invariant. Two related hardenings on the same theme: the session opts out of system-alert interruptions (every interruption stops the engine, and every engine stop is a chance to hit the check-then-call window where an AVFAudio call raises an uncatchable ObjC exception), and the 158 MB model download is size-validated before being committed — a transfer cut off by a cell handoff, or a captive portal answering with HTML under a 200, used to be cached as "the model" forever and handed to ONNX Runtime as a protobuf on every later separation. The black bars are a measurement mismatch. The pager's GeometryReader is laid out inside the safe area while its ScrollView ignores it, so `proxy.size.height` was short by exactly the insets it reports — while every page's `containerRelativeFrame(.vertical)` is the full window height. The shared backdrop was therefore three insets' worth shorter than the content it backs and, centred as a `.background`, left uncovered bands of flat `systemGroupedBackground` (black in dark mode) at top and bottom, with the album gradient slid out of register with the Now Playing page. Adding the insets back makes the backdrop span exactly its three pages, so the artwork gradient now runs edge to edge under the status bar and home indicator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- App/Continuity/Views/MainPagerView.swift | 11 ++- App/Continuity/Views/NowPlayingView.swift | 4 +- CLAUDE.md | 26 ++++++ .../Sources/Ingest/StemModelStore.swift | 44 +++++++++- .../ContinuityKit/Sources/Playback/Deck.swift | 33 +++++-- .../Playback/Player+AudioSession.swift | 6 ++ .../Sources/Playback/Player+Transport.swift | 3 + .../Sources/Playback/Player.swift | 43 +++++++--- .../PlayerClockSafetyTests.swift | 86 +++++++++++++++++++ 9 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 Packages/ContinuityKit/Tests/PlaybackTests/PlayerClockSafetyTests.swift diff --git a/App/Continuity/Views/MainPagerView.swift b/App/Continuity/Views/MainPagerView.swift index ff4ab30..24191b0 100644 --- a/App/Continuity/Views/MainPagerView.swift +++ b/App/Continuity/Views/MainPagerView.swift @@ -15,7 +15,16 @@ struct MainPagerView: View { // insets are re-applied per page below. GeometryReader { proxy in let insets = proxy.safeAreaInsets - let pageHeight = proxy.size.height + // The ScrollView below ignores the safe area, so it — and therefore every page's + // `containerRelativeFrame(.vertical)` — is the FULL window height. This + // GeometryReader is laid out inside the safe area, so `proxy.size.height` is short + // by exactly the insets it reports. Measuring the backdrop with the short value + // made it three insets' worth shorter than the content it backs; centred as a + // `.background`, that left an uncovered band of flat `systemGroupedBackground` at + // the top and bottom (black in dark mode) and slid the album gradient out of + // register with the Now Playing page. Add the insets back so the backdrop spans + // exactly the three pages it is drawing. + let pageHeight = proxy.size.height + insets.top + insets.bottom ScrollView(.vertical) { // Every page gets HARD insets (the pager hides the window safe area, and // neither NavigationStack bars nor safe-area piercing behave inside scroll diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index 9ce81ee..b158080 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -301,7 +301,9 @@ private struct ScrubberBar: View { VStack(spacing: 4) { Slider( value: Binding( - get: { isEditing ? scrubValue : player.position }, + // Clamped into the slider's own range: a value outside it (or a non-finite + // one from a disturbed render clock) is a layout trap, not a visual glitch. + get: { min(max(isEditing ? scrubValue : player.position, 0), max(player.duration, 0.1)) }, set: { scrubValue = $0 } ), in: 0...max(player.duration, 0.1), diff --git a/CLAUDE.md b/CLAUDE.md index 0c9d70f..a1dd47a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,26 @@ branch: require the render clock to be *fresh* (host-time-valid, ≤ now, within anchor at `mach_absolute_time()+0.03s`, else fall back to plain `play()` calls. Any new `play(at:)` usage must follow the same freshness rule. +### Non-finite playback clock (resolved) +Transient crashes that clustered "when the phone is moving". The app uses **no** motion, +altimeter or location APIs, so no sensor was involved — movement is a proxy for **audio route +churn** (AirPods connecting/dropping, car stereos, a jostled cable) and network path changes. +Around a route or `AVAudioEngineConfigurationChange`, `AVAudioPlayerNode`'s time base can report +an unset sample rate, and `sampleTime / 0` is `.infinity` in Swift, not an error. That value flowed +out of `Deck.elapsed` into `Player.position` and from there into: +- `AVAudioFramePosition(seconds * sampleRate)` in `Deck.scheduleSegment` — **traps** on a + non-finite double, and `scheduleSegment` raises an uncatchable ObjC exception on a negative + start frame; +- SwiftUI `frame`/`Shape.trim`/`Slider` — all of which trap on a non-finite number. + +Fixes (keep all): `Deck.elapsed` requires sample-time validity + a positive sample rate and +returns 0 otherwise; `scheduleSegment` clamps into the file before converting; `Player.duration` +and every published fraction go through `Player.fraction` (**`min`/`max` do NOT sanitize NaN — +`min(NaN, 1)` is NaN**); `seek` rejects non-finite targets; the 1 Hz countdown clamps before +`Int(_:)`. Pinned by `PlaybackTests/PlayerClockSafetyTests`. Also: the session opts out of system +alert interruptions, and the stem model download is size-validated before being cached (a cut-off +transfer or a captive-portal page used to be cached as "the model" forever). + ### UI architecture decisions - **20 Hz `position` writes must not reach heavy views**: only leaf views (`TrackProgressRing`, `ScrubberBar`, `MiniProgressLine`) read `player.position` / @@ -60,6 +80,12 @@ anchor at `mach_absolute_time()+0.03s`, else fall back to plain `play()` calls. padding, and full-bleed surfaces are page `.background`s behind that padding. NavigationStack bars and `safeAreaPadding` do NOT behave inside scroll content — that's why it's built this way (black bars / status-bar overlap regressions otherwise). + **The pager's `GeometryReader` sits INSIDE the safe area while its ScrollView ignores it**, so + `proxy.size.height` is short by exactly the insets it reports, and every page's + `containerRelativeFrame(.vertical)` is the full window height. `pageHeight` must therefore be + `proxy.size.height + insets.top + insets.bottom`. Measuring the shared backdrop with the short + value is what put flat `systemGroupedBackground` (black in dark mode) bands at the top and + bottom and slid the album gradient out of register with Now Playing. - **Transitions**: skip button starts a 5s blend (`Player.skipTransitionDurationSeconds`), clamped to remaining audio (`effectiveEndSeconds - position`; hard-advance under 1s) so short tracks don't end in an audible cut. `isUserInitiatedSkipTransition` prevents double-spend. diff --git a/Packages/ContinuityKit/Sources/Ingest/StemModelStore.swift b/Packages/ContinuityKit/Sources/Ingest/StemModelStore.swift index 208dfdc..a143233 100644 --- a/Packages/ContinuityKit/Sources/Ingest/StemModelStore.swift +++ b/Packages/ContinuityKit/Sources/Ingest/StemModelStore.swift @@ -7,7 +7,9 @@ enum StemModelStore { static let remoteURL = URL(string: "https://huggingface.co/StemSplitio/htdemucs-ft-vocals-onnx/resolve/main/htdemucs_ft_vocals_fp16weights.onnx")! static let fileName = "htdemucs_ft_vocals_fp16weights.onnx" - static var directory: URL { + /// Memoized like the other cache directories — `localURL` and `isDownloaded` read it, and + /// as a computed property each read ran a `createDirectory` + `setResourceValues` pair. + static let directory: URL = { // Application Support, not Caches: the OS evicts Caches under storage pressure, and a // silently re-downloaded 158 MB model at play time is both a delay and a memory/network // spike stacked exactly on playback start (see the jetsam RCA). Excluded from backup — @@ -19,7 +21,12 @@ enum StemModelStore { values.isExcludedFromBackup = true try? dir.setResourceValues(values) return dir - } + }() + + /// Floor for "this file is plausibly the model" (it is ~158 MB). Anything under this is a + /// truncated transfer or an error page served with a 200 — never something to hand to ONNX + /// Runtime as a protobuf. + private static let minimumPlausibleBytes: Int64 = 100_000_000 /// Pre-move location (Caches). Checked once so existing installs don't re-download. private static var legacyURL: URL { @@ -32,10 +39,21 @@ enum StemModelStore { static var isDownloaded: Bool { FileManager.default.fileExists(atPath: localURL.path) } /// Returns the local model URL, downloading it first if needed. + /// + /// The download is validated before it is committed. A network path change mid-transfer — + /// a cell/Wi-Fi handoff while walking, which is exactly when this 158 MB fetch is most + /// likely to be interrupted — can land a short file, and a captive portal can answer with + /// an HTML page under a 200. Either one, cached as "the model", is then fed to ONNX Runtime + /// as a protobuf on every future separation and fails (or worse) forever. static func ensureModel() async throws -> URL { - if isDownloaded { return localURL } + if isDownloaded { + if isPlausibleModel(at: localURL) { return localURL } + // An earlier run cached something that isn't the model. Drop it and re-fetch. + try? FileManager.default.removeItem(at: localURL) + } // Migrate a surviving Caches copy instead of re-downloading it. if FileManager.default.fileExists(atPath: legacyURL.path), + isPlausibleModel(at: legacyURL), (try? FileManager.default.moveItem(at: legacyURL, to: localURL)) != nil { return localURL } @@ -44,6 +62,17 @@ enum StemModelStore { if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { throw StemSeparationError.inference("model download HTTP \(http.statusCode)") } + let downloadedBytes = fileSize(at: temp) + // Short of what the server promised means the transfer was cut off. Larger is fine — + // a content-encoded response decodes bigger than its declared length. + let expected = response.expectedContentLength + if expected > 0, downloadedBytes < expected { + throw StemSeparationError.inference( + "model download truncated: \(downloadedBytes) of \(expected) bytes") + } + guard downloadedBytes >= minimumPlausibleBytes else { + throw StemSeparationError.inference("model download too small: \(downloadedBytes) bytes") + } do { try FileManager.default.moveItem(at: temp, to: localURL) } catch { @@ -53,4 +82,13 @@ enum StemModelStore { } return localURL } + + private static func isPlausibleModel(at url: URL) -> Bool { + fileSize(at: url) >= minimumPlausibleBytes + } + + private static func fileSize(at url: URL) -> Int64 { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return Int64(values?.fileSize ?? 0) + } } diff --git a/Packages/ContinuityKit/Sources/Playback/Deck.swift b/Packages/ContinuityKit/Sources/Playback/Deck.swift index 76516f5..adb946a 100644 --- a/Packages/ContinuityKit/Sources/Playback/Deck.swift +++ b/Packages/ContinuityKit/Sources/Playback/Deck.swift @@ -201,10 +201,25 @@ final class Deck { } /// Seconds elapsed since this deck last (re)started (from the always-present accompaniment). + /// + /// Defensive about the render clock, because this value is the root of every number the app + /// computes about playback. Around a route or configuration change — AirPods connecting, a + /// car stereo, a jostled cable — the node's time base can briefly report an unset sample + /// rate or a sample time from the previous engine run. `sampleTime / 0` is `.infinity` in + /// Swift, not an error, so that used to flow into `Player.position` and from there into + /// `AVAudioFramePosition(seconds * sampleRate)`, which **traps** on a non-finite double, and + /// into SwiftUI frames and `Shape.trim`, which trap on one too. + /// + /// That is the crash class that clusters "when the phone is moving": moving is when routes + /// change. An unusable clock now reads as 0 — the position simply doesn't advance for a + /// tick — instead of poisoning everything downstream. var elapsed: TimeInterval { - guard let nodeTime = accompPlayer.lastRenderTime, - let playerTime = accompPlayer.playerTime(forNodeTime: nodeTime) else { return 0 } - return Double(playerTime.sampleTime) / playerTime.sampleRate + guard let nodeTime = accompPlayer.lastRenderTime, nodeTime.isSampleTimeValid, + let playerTime = accompPlayer.playerTime(forNodeTime: nodeTime), + playerTime.isSampleTimeValid, playerTime.sampleRate > 0 else { return 0 } + let seconds = Double(playerTime.sampleTime) / playerTime.sampleRate + guard seconds.isFinite, seconds >= 0 else { return 0 } + return seconds } /// Frame-accurate seek for a real-file (or stem) deck. Returns `false` for a synth deck. @@ -219,9 +234,17 @@ final class Deck { } private func scheduleSegment(_ player: AVAudioPlayerNode, file: AVAudioFile, from seconds: TimeInterval) { - let startFrame = AVAudioFramePosition(seconds * file.processingFormat.sampleRate) + // Clamp into the file before converting. `Double` → `AVAudioFramePosition` traps on + // infinity, NaN, or anything outside Int64, and `scheduleSegment` raises an uncatchable + // ObjC exception on a negative start frame. Both are reachable from a disturbed render + // clock (see `elapsed`) or a negative clock baseline, and neither is worth dying for: + // an offset that isn't a real position in this file starts it from the beginning. + let sampleRate = file.processingFormat.sampleRate + let fileSeconds = sampleRate > 0 ? Double(file.length) / sampleRate : 0 + let safeSeconds = seconds.isFinite ? min(max(0, seconds), fileSeconds) : 0 + let startFrame = AVAudioFramePosition(safeSeconds * sampleRate) player.stop() - guard startFrame < file.length else { return } + guard startFrame >= 0, startFrame < file.length else { return } player.scheduleSegment(file, startingFrame: startFrame, frameCount: AVAudioFrameCount(file.length - startFrame), at: nil) } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift b/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift index 4745814..d5570c8 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+AudioSession.swift @@ -190,6 +190,12 @@ extension Player { func configureSession() { let session = AVAudioSession.sharedInstance() try? session.setCategory(.playback, mode: .default) + // Every system interruption stops the engine, and every engine stop is a chance to hit + // the check-then-call window where an AVFAudio node call raises an uncatchable ObjC + // exception. A media playback app is allowed to opt out of the incidental ones (system + // alerts) — fewer interruptions is fewer chances. Best-effort: never worth failing + // session setup over. + try? session.setPrefersNoInterruptionsFromSystemAlerts(true) try? session.setActive(true) } } diff --git a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift index f8018d5..459e523 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transport.swift @@ -282,6 +282,9 @@ extension Player { } public func seek(to seconds: TimeInterval) { + // A non-finite scrub target is not a position — ignore it rather than reinterpret it as + // zero. `min`/`max` don't sanitize NaN, and everything below feeds a frame conversion. + guard seconds.isFinite else { return } let clamped = max(0, min(seconds, duration)) cancelTransition() // re-evaluate the transition window from the new position // Paused pre-audio scrub: metadata-only — move the clock and stage the seek for when diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index 36742be..b47fe48 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -82,9 +82,28 @@ public final class Player { } /// Never zero while a track is loaded — uses the deck's resolved duration, falling back to the /// model whenever no deck is loaded (pre-audio staging, or before the first load). + /// + /// Guaranteed finite: this is the scrubber's range, the divisor for every progress fraction, + /// and the basis of the transition schedule, and SwiftUI traps rather than degrades when a + /// non-finite number reaches layout. public var duration: TimeInterval { - if let deck = audio?.current, deck.loadedDuration > 0 { return deck.loadedDuration } - return currentTrack?.durationSeconds ?? 0 + let resolved: TimeInterval + if let deck = audio?.current, deck.loadedDuration > 0 { + resolved = deck.loadedDuration + } else { + resolved = currentTrack?.durationSeconds ?? 0 + } + return resolved.isFinite && resolved > 0 ? resolved : 0 + } + + /// Clamps a progress fraction into `0...1`, mapping any non-finite value to 0. + /// + /// `min`/`max` do NOT sanitize NaN — `min(NaN, 1)` is NaN — so every fraction this type + /// publishes goes through here. A NaN reaching `Shape.trim` or a SwiftUI `frame(width:)` + /// is a hard crash, not a visual glitch. + static func fraction(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) } /// Progress fraction (0…1) for UI rings/bars. During a blend it MORPHS from the outgoing @@ -92,21 +111,21 @@ public final class Player { /// moment the decks swap, the displayed value already equals the incoming fraction, so the /// indicator glides into the next song instead of snapping to it. public var displayProgress: Double { - let current = duration > 0 ? min(max(position / duration, 0), 1) : 0 + let current = duration > 0 ? Player.fraction(position / duration) : 0 guard isTransitioning, let audio, queue.indices.contains(transitionTargetIndex) else { return current } let incoming = queue[transitionTargetIndex] let incomingDuration = audio.idle.loadedDuration > 0 ? audio.idle.loadedDuration : incoming.durationSeconds - guard incomingDuration > 0 else { return current } + guard incomingDuration.isFinite, incomingDuration > 0 else { return current } // The tick's snapshot, not a fresh `Deck.elapsed`: every reader of this property is a // 20 Hz leaf, and each `elapsed` call round-trips through AVAudioEngine for a render // timestamp and a player time. Three progress views during a blend meant six of those // per tick for a value that only moves once per tick anyway. let incomingPosition = incomingStartOffset + incomingDeckElapsed - let incomingFraction = min(max(incomingPosition / incomingDuration, 0), 1) - let weight = min(max(transitionProgress, 0), 1) - return current + (incomingFraction - current) * weight + let incomingFraction = Player.fraction(incomingPosition / incomingDuration) + let weight = Player.fraction(transitionProgress) + return Player.fraction(current + (incomingFraction - current) * weight) } // MARK: Engine / decks @@ -450,7 +469,8 @@ public final class Player { /// Republishes the coarse countdown. Called from `tick()` and from `persistState()` (the /// funnel every playback discontinuity already goes through), so it can never go stale. func refreshTransitionCountdown() { - let seconds = secondsUntilTransition.map { Int($0.rounded()) } + // `Int(_: Double)` traps on infinity and NaN; a day is far past any real countdown. + let seconds = secondsUntilTransition.map { Int(min(max(0, $0), 86_400).rounded()) } if transitionCountdownSeconds != seconds { transitionCountdownSeconds = seconds } } @@ -463,8 +483,11 @@ public final class Player { recoverPlayback(force: true) return } + // If the clock baseline has been corrupted (a stale render time survived long enough + // to poison it), hold the last good position rather than publish a number that traps + // SwiftUI layout on its way to the scrubber. let elapsed = baselineSeconds + audio.current.elapsed - position = elapsed + if elapsed.isFinite, elapsed >= 0 { position = elapsed } // Cosmetic: ease a just-transitioned track's key-sync pitch back to true. Touches only the // deck's timePitch node (never `position`), so it stays off the heavy-view tick path. advancePitchSettle() @@ -502,7 +525,7 @@ public final class Player { let gains = plan.gains(position: incomingElapsed, startPosition: 0) audio.current.volume = Float(gains.outgoing) audio.idle.volume = Float(gains.incoming) - let progress = plan.progress(position: incomingElapsed, startPosition: 0) + let progress = Player.fraction(plan.progress(position: incomingElapsed, startPosition: 0)) transitionProgress = progress // Bass-swap: fade the incoming low end in so two basslines don't stack into mud. if blendSettings.bassSwapEnabled { diff --git a/Packages/ContinuityKit/Tests/PlaybackTests/PlayerClockSafetyTests.swift b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerClockSafetyTests.swift new file mode 100644 index 0000000..39188f7 --- /dev/null +++ b/Packages/ContinuityKit/Tests/PlaybackTests/PlayerClockSafetyTests.swift @@ -0,0 +1,86 @@ +import XCTest +import Domain +@testable import Playback + +/// Pins the "no non-finite number ever leaves the player" invariant. +/// +/// Around a route or audio-configuration change — AirPods connecting, a car stereo, a jostled +/// cable, all of which correlate with the phone moving — the render clock can report an unset +/// sample rate, and `sampleTime / 0` is `.infinity` in Swift rather than an error. Everything +/// downstream traps on that rather than degrading: `Double` → `AVAudioFramePosition` traps on a +/// non-finite value, and so do SwiftUI's layout and `Shape.trim`. `min`/`max` are no defence, +/// because `min(NaN, 1)` is NaN. +/// +/// These tests poison the clock directly and assert the published values stay usable. None of +/// them materializes the audio stack. +@MainActor +final class PlayerClockSafetyTests: XCTestCase { + + private func makeTrack(sortIndex: Int = 0) -> Track { + Track(title: "T\(sortIndex)", artist: "Test", durationSeconds: 180, + gradientSeed: sortIndex, sortIndex: sortIndex) + } + + private func stagedPlayer(trackCount: Int = 2) -> Player { + let player = Player() + player.queue = (0.. Date: Thu, 10 Sep 2026 23:37:53 +0000 Subject: [PATCH 7/7] Stop trapping on a duplicate track id `Dictionary(uniqueKeysWithValues:)` traps when a key repeats, and all three uses sat somewhere a trap is the wrong answer: two on the cold-launch path (session restore and the queue-exhausted refill, both building an id lookup from a store fetch) and one behind the Flow toggle. Keeping the first occurrence is the correct behaviour for every one of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MVt2eFfLU2wrwxFSnwCAhc --- App/Continuity/Views/RootView.swift | 8 ++++++-- App/Continuity/Views/UpNextView.swift | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/App/Continuity/Views/RootView.swift b/App/Continuity/Views/RootView.swift index 7e27c6d..2073650 100644 --- a/App/Continuity/Views/RootView.swift +++ b/App/Continuity/Views/RootView.swift @@ -94,7 +94,10 @@ struct RootView: View { var descriptor = FetchDescriptor() descriptor.propertiesToFetch = [\.id] let tracks = (try? modelContext.fetch(descriptor)) ?? [] - let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) + // `uniquingKeysWith`, not `uniqueKeysWithValues`: the latter traps on a + // duplicate key, and trapping is not the right answer to a store that + // handed back the same row twice. + let byID = Dictionary(tracks.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) return ids.compactMap { byID[$0] } } LibraryCleanup.sweepOrphanedFiles(in: modelContext) @@ -249,7 +252,8 @@ struct RootView: View { var descriptor = FetchDescriptor() descriptor.propertiesToFetch = [\.id] let stored = (try? modelContext.fetch(descriptor)) ?? [] - let byID = Dictionary(uniqueKeysWithValues: stored.map { ($0.id, $0) }) + // Never trap on a duplicate id here — this is the cold-launch path. + let byID = Dictionary(stored.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) player.restore(state, resolving: byID) if player.currentTrack != nil { return } // Every persisted track was deleted — fall through to the first-run seed. diff --git a/App/Continuity/Views/UpNextView.swift b/App/Continuity/Views/UpNextView.swift index 284190e..e8ec49a 100644 --- a/App/Continuity/Views/UpNextView.swift +++ b/App/Continuity/Views/UpNextView.swift @@ -91,7 +91,9 @@ struct UpNextView: View { let tracks = [current] + player.upcomingTracks let items = tracks.map { FlowItem(id: $0.id, bpm: $0.bpm, camelotCode: $0.camelotCode) } let ordered = FlowOrdering.order(items, startingAt: current.id) - let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) + // `uniquingKeysWith`, not `uniqueKeysWithValues`: a repeated id in the queue would trap + // the app on a Flow toggle rather than just ordering one track once. + let byID = Dictionary(tracks.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) player.replaceUpcoming(with: ordered.filter { $0 != current.id }.compactMap { byID[$0] }) } }