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/MainPagerView.swift b/App/Continuity/Views/MainPagerView.swift index 79f2211..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 @@ -162,7 +171,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/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index c182941..b158080 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") } @@ -302,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), @@ -329,7 +330,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 +344,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..2073650 100644 --- a/App/Continuity/Views/RootView.swift +++ b/App/Continuity/Views/RootView.swift @@ -88,8 +88,16 @@ 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())) ?? [] - let byID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) + // 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)) ?? [] + // `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) @@ -236,10 +244,16 @@ 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)) ?? [] + // 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. @@ -247,6 +261,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..febb426 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,137 @@ 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 + // ~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 + }() + + /// 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 + // 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 + 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 @@ -100,7 +223,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 } } } @@ -111,6 +240,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 +255,47 @@ 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 } + + // 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 + 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,14 +313,13 @@ 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) } /// 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") { @@ -169,9 +331,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 +357,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 +374,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 +394,76 @@ 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 + } + 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/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/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] }) } } diff --git a/CLAUDE.md b/CLAUDE.md index 71eb7a5..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. @@ -101,18 +127,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/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...`. 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+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+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+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 91c9f88..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 @@ -111,33 +118,43 @@ 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 + 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. - reconcileStemLinks(track, in: context) + reconcileStemLinks(track, in: context, using: cacheIndex) 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 } } + if needsSave { try? context.save() } } // MARK: - Source sync @@ -445,6 +462,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/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/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 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+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+Transitions.swift b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift index 7966136..349a342 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player+Transitions.swift @@ -44,6 +44,7 @@ extension Player { incoming.volume = 0 incoming.rate = 1 incomingStartOffset = 0 + incomingDeckElapsed = 0 // Beatmatch: when both tracks have a detected tempo and the stretch is modest, retempo the // incoming deck to the outgoing track so they beat together through the blend. Otherwise @@ -214,6 +215,7 @@ extension Player { activeTransitionDurationSeconds = 0 isUserInitiatedSkipTransition = false incomingStartOffset = 0 + incomingDeckElapsed = 0 incomingPitchShiftSemitones = 0 incomingRate = 1 transitionProgress = 0 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 d3b3733..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,17 +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 } - let incomingPosition = incomingStartOffset + audio.idle.elapsed - let incomingFraction = min(max(incomingPosition / incomingDuration, 0), 1) - let weight = min(max(transitionProgress, 0), 1) - return current + (incomingFraction - current) * weight + 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 = Player.fraction(incomingPosition / incomingDuration) + let weight = Player.fraction(transitionProgress) + return Player.fraction(current + (incomingFraction - current) * weight) } // MARK: Engine / decks @@ -126,6 +149,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 @@ -418,6 +445,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 +456,24 @@ 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() { + // `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 } + } + private func tick() { guard isPlaying, let audio else { return } // Configuration changes can report the engine as running in their callback, then stop it @@ -435,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() @@ -470,10 +521,11 @@ 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) - 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 { @@ -526,6 +578,8 @@ public final class Player { pushHistory(currentTrack) stopPlayback() } + // Last thing in the tick, so it reflects any transition this tick started or finished. + refreshTransitionCountdown() } } 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..