From c39c2bd7574dabccc53cc635f4fd5ffca9370049 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 6 Nov 2026 14:47:53 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates and enhancements --- KJMix Pro.xcodeproj/project.pbxproj | 62 +++--- KJamsClone/AppleMusic.swift | 295 ++++++++++++++++++++++++++ KJamsClone/DJWorkstation.swift | 19 ++ KJamsClone/Player.swift | 237 ++++++++++++++++----- KJamsCloneTests/KJamsCloneTests.swift | 112 +++++++++- docs/DJ_WORKSTATION.md | 13 +- 6 files changed, 649 insertions(+), 89 deletions(-) create mode 100644 KJamsClone/AppleMusic.swift diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index ca9055b..e74efa2 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -7,13 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60000000000000000000002; }; - B50000000000000000000001 /* DJLoopEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50000000000000000000002; }; - B40000000000000000000001 /* DJAutomix.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40000000000000000000002; }; - B30000000000000000000001 /* DJAudioRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002; }; - B20000000000000000000001 /* DJXMLMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002; }; - B20000000000000000000003 /* MIDIMappings in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004; }; - B10000000000000000000001 /* DJWorkstation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002; }; 000000000000000000000065 /* KJMixProApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000001 /* KJMixProApp.swift */; }; 000000000000000000000066 /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000002 /* Library.swift */; }; 000000000000000000000067 /* CDG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000003 /* CDG.swift */; }; @@ -27,6 +20,14 @@ AE0000000000000000000002 /* LibraryScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000000000000000000001 /* LibraryScanner.swift */; }; AE0000000000000000000011 /* DJWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000000000000000000010 /* DJWorkspace.swift */; }; AF0000000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000001 /* Assets.xcassets */; }; + B10000000000000000000001 /* DJWorkstation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* DJWorkstation.swift */; }; + B20000000000000000000001 /* DJXMLMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* DJXMLMapping.swift */; }; + B20000000000000000000003 /* MIDIMappings in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* MIDIMappings */; }; + B30000000000000000000001 /* DJAudioRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* DJAudioRouting.swift */; }; + B40000000000000000000001 /* DJAutomix.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40000000000000000000002 /* DJAutomix.swift */; }; + B50000000000000000000001 /* DJLoopEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50000000000000000000002 /* DJLoopEditing.swift */; }; + B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60000000000000000000002 /* DJNativeDragDrop.swift */; }; + B70000000000000000000001 /* AppleMusic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000002 /* AppleMusic.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,13 +41,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - B60000000000000000000002 /* DJNativeDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJNativeDragDrop.swift; sourceTree = ""; }; - B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = ""; }; - B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = ""; }; - B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = ""; }; - B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = ""; }; - B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = ""; }; - B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = ""; }; 000000000000000000000001 /* KJMixProApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KJMixProApp.swift; sourceTree = ""; }; 000000000000000000000002 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = ""; }; 000000000000000000000003 /* CDG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CDG.swift; sourceTree = ""; }; @@ -62,6 +56,14 @@ AE0000000000000000000001 /* LibraryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryScanner.swift; sourceTree = ""; }; AE0000000000000000000010 /* DJWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkspace.swift; sourceTree = ""; }; AF0000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = ""; }; + B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = ""; }; + B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = ""; }; + B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = ""; }; + B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = ""; }; + B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = ""; }; + B60000000000000000000002 /* DJNativeDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJNativeDragDrop.swift; sourceTree = ""; }; + B70000000000000000000002 /* AppleMusic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusic.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -80,12 +82,13 @@ children = ( 000000000000000000000001 /* KJMixProApp.swift */, B10000000000000000000002 /* DJWorkstation.swift */, - B20000000000000000000002 /* DJXMLMapping.swift */, - B30000000000000000000002 /* DJAudioRouting.swift */, - B40000000000000000000002 /* DJAutomix.swift */, - B50000000000000000000002 /* DJLoopEditing.swift */, - B60000000000000000000002 /* DJNativeDragDrop.swift */, - B20000000000000000000004 /* MIDIMappings */, + B20000000000000000000002 /* DJXMLMapping.swift */, + B30000000000000000000002 /* DJAudioRouting.swift */, + B40000000000000000000002 /* DJAutomix.swift */, + B50000000000000000000002 /* DJLoopEditing.swift */, + B60000000000000000000002 /* DJNativeDragDrop.swift */, + B70000000000000000000002 /* AppleMusic.swift */, + B20000000000000000000004 /* MIDIMappings */, 000000000000000000000002 /* Library.swift */, 000000000000000000000003 /* CDG.swift */, 000000000000000000000004 /* Importer.swift */, @@ -194,7 +197,7 @@ buildActionMask = 2147483647; files = ( AF0000000000000000000002 /* Assets.xcassets in Resources */, - B20000000000000000000003 /* MIDIMappings in Resources */, + B20000000000000000000003 /* MIDIMappings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -207,11 +210,12 @@ files = ( 000000000000000000000065 /* KJMixProApp.swift in Sources */, B10000000000000000000001 /* DJWorkstation.swift in Sources */, - B20000000000000000000001 /* DJXMLMapping.swift in Sources */, - B30000000000000000000001 /* DJAudioRouting.swift in Sources */, - B40000000000000000000001 /* DJAutomix.swift in Sources */, - B50000000000000000000001 /* DJLoopEditing.swift in Sources */, - B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */, + B20000000000000000000001 /* DJXMLMapping.swift in Sources */, + B30000000000000000000001 /* DJAudioRouting.swift in Sources */, + B40000000000000000000001 /* DJAutomix.swift in Sources */, + B50000000000000000000001 /* DJLoopEditing.swift in Sources */, + B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */, + B70000000000000000000001 /* AppleMusic.swift in Sources */, 000000000000000000000066 /* Library.swift in Sources */, 000000000000000000000067 /* CDG.swift in Sources */, 000000000000000000000068 /* Importer.swift in Sources */, @@ -281,11 +285,12 @@ INFOPLIST_KEY_CFBundleDisplayName = "KJMix Pro"; INFOPLIST_KEY_CFBundleName = "KJMix Pro"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "KJMix Pro uses Apple Music to browse your library and playlists, search the catalog and play the music you choose."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.6.9; OTHER_LDFLAGS = "-lsqlite3"; - PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; + PRODUCT_BUNDLE_IDENTIFIER = com.kmixpro.app; PRODUCT_MODULE_NAME = KJamsClone; PRODUCT_NAME = "KJMix Pro"; SDKROOT = macosx; @@ -308,11 +313,12 @@ INFOPLIST_KEY_CFBundleDisplayName = "KJMix Pro"; INFOPLIST_KEY_CFBundleName = "KJMix Pro"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSAppleMusicUsageDescription = "KJMix Pro uses Apple Music to browse your library and playlists, search the catalog and play the music you choose."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; MACOSX_DEPLOYMENT_TARGET = 13.0; MARKETING_VERSION = 0.6.9; OTHER_LDFLAGS = "-lsqlite3"; - PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; + PRODUCT_BUNDLE_IDENTIFIER = com.kmixpro.app; PRODUCT_MODULE_NAME = KJamsClone; PRODUCT_NAME = "KJMix Pro"; SDKROOT = macosx; diff --git a/KJamsClone/AppleMusic.swift b/KJamsClone/AppleMusic.swift new file mode 100644 index 0000000..9a25dd8 --- /dev/null +++ b/KJamsClone/AppleMusic.swift @@ -0,0 +1,295 @@ +import SwiftUI +import MusicKit + +struct AppleMusicTrack: Identifiable, Equatable { + let id: String + let title: String + let artist: String + let album: String + let duration: Double + let artwork: URL? +} +struct AppleMusicPlaylist: Identifiable, Equatable { let id: String;let name: String } +struct AppleMusicPage { let tracks: [AppleMusicTrack];let hasMore: Bool } +enum AppleMusicAccess { case notDetermined,authorized,denied,restricted } +struct AppleMusicQueueItem: Identifiable, Equatable { let id: String;let title: String;let artist: String;let current: Bool } +struct AppleMusicPlayback: Equatable { var title = "";var artist = "";var playing = false;var position: Double = 0;var duration: Double = 0;var queue = [AppleMusicQueueItem]() } + +@MainActor protocol AppleMusicClient: AnyObject { + var access: AppleMusicAccess { get } + func authorize() async -> AppleMusicAccess + func subscribed() async throws -> Bool + func playlists() async throws -> [AppleMusicPlaylist] + func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage + func play(_ id: String) async throws + func enqueue(_ id: String) async throws + func resume() async throws + func pause() + func stop() + func seek(_ seconds: Double) + func next() async throws + var playback: AppleMusicPlayback { get } +} + +/// Public MusicKit playback stays inside Apple's player; protected streams are +/// never passed to AVAudioFile, waveform analysis or the local deck DSP graph. +@available(macOS 14.0, *) @MainActor final class NativeAppleMusicClient: AppleMusicClient { + private var tracks = [String:MusicKit.Song]() + private var playlistItems = [String:MusicKit.Playlist]() + private var musicPlayer: ApplicationMusicPlayer { .shared } + var access: AppleMusicAccess { + switch MusicAuthorization.currentStatus { + case .authorized:return .authorized + case .denied:return .denied + case .restricted:return .restricted + default:return .notDetermined + } + } + func authorize() async -> AppleMusicAccess { _ = await MusicAuthorization.request();return access } + func subscribed() async throws -> Bool { try await MusicSubscription.current.canPlayCatalogContent } + private func remember(_ songs: [MusicKit.Song]) -> [AppleMusicTrack] { + songs.map { song in + tracks[song.id.rawValue] = song + return AppleMusicTrack(id:song.id.rawValue,title:song.title,artist:song.artistName,album:song.albumTitle ?? "",duration:song.duration ?? 0,artwork:song.artwork?.url(width:80,height:80)) + } + } + func playlists() async throws -> [AppleMusicPlaylist] { + var request = MusicLibraryRequest();request.limit = 100 + var result = [AppleMusicPlaylist]() + while true { + try Task.checkCancellation() + let response = try await request.response() + for item in response.items { playlistItems[item.id.rawValue] = item;result.append(.init(id:item.id.rawValue,name:item.name)) } + if !response.items.hasNextBatch { break };request.offset += response.items.count + } + return result + } + func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage { + if !query.isEmpty { + var request = MusicCatalogSearchRequest(term:query,types:[MusicKit.Song.self]);request.limit = 50;request.offset = offset + let response = try await request.response() + return .init(tracks:remember(Array(response.songs)),hasMore:response.songs.hasNextBatch) + } + if let playlist,let item = playlistItems[playlist] { + let detailed = try await item.with([.tracks]) + guard var collection = detailed.tracks else { return .init(tracks:[],hasMore:false) } + var all = Array(collection) + while all.count < offset+50,collection.hasNextBatch,let next = try await collection.nextBatch(limit:50) { + try Task.checkCancellation();collection = next;all.append(contentsOf:next) + } + let songs = all.dropFirst(offset).prefix(50).compactMap { track -> MusicKit.Song? in + if case .song(let song) = track { return song };return nil + } + return .init(tracks:remember(songs),hasMore:all.count > offset+50 || collection.hasNextBatch) + } + var request = MusicLibraryRequest();request.limit = 50;request.offset = offset + let response = try await request.response() + return .init(tracks:remember(Array(response.items)),hasMore:response.items.hasNextBatch) + } + private func song(_ id: String) throws -> MusicKit.Song { + guard let song = tracks[id] else { throw LibraryError.message("Refresh Apple Music and select the track again.") };return song + } + func play(_ id: String) async throws { musicPlayer.queue = .init(for:[try song(id)]);try await musicPlayer.play() } + func enqueue(_ id: String) async throws { try await musicPlayer.queue.insert(song(id),position:.tail) } + func resume() async throws { try await musicPlayer.play() } + func pause() { musicPlayer.pause() } + func stop() { musicPlayer.stop() } + func seek(_ seconds: Double) { musicPlayer.playbackTime = max(0,seconds) } + func next() async throws { try await musicPlayer.skipToNextEntry() } + var playback: AppleMusicPlayback { + let entry = musicPlayer.queue.currentEntry + var duration: Double = 0 + if case .song(let song) = entry?.item { duration = song.duration ?? 0 } + let queue = musicPlayer.queue.entries.map { AppleMusicQueueItem(id:$0.id,title:$0.title,artist:$0.subtitle ?? "",current:$0.id == entry?.id) } + return .init(title:entry?.title ?? "",artist:entry?.subtitle ?? "",playing:musicPlayer.state.playbackStatus == .playing,position:musicPlayer.playbackTime,duration:duration,queue:queue) + } +} + +@available(macOS 14.0, *) @MainActor final class AppleMusicTransportState: ObservableObject { + @Published var value = AppleMusicPlayback() +} +@available(macOS 14.0, *) @MainActor final class AppleMusicSession: ObservableObject { + @Published private(set) var access: AppleMusicAccess + @Published private(set) var canPlay = false + @Published private(set) var tracks = [AppleMusicTrack]() + @Published private(set) var playlists = [AppleMusicPlaylist]() + @Published private(set) var busy = false + @Published private(set) var loadingMore = false + @Published private(set) var hasMore = false + let transport = AppleMusicTransportState() + var playback: AppleMusicPlayback { + get { transport.value } + set { if newValue != transport.value { transport.value = newValue } } + } + @Published var error: String? + @Published var query = "" + @Published var playlist: String? + private let client: AppleMusicClient + private var browseTask: Task? + private var playbackTask: Task? + private var generation = UUID() + init(client: AppleMusicClient? = nil) { let client = client ?? NativeAppleMusicClient();self.client = client;access = client.access } + deinit { browseTask?.cancel();playbackTask?.cancel() } + func connect() async { + guard !busy else { return };browseTask?.cancel();generation = UUID();canPlay = false;tracks = [];busy = true;error = nil + access = await client.authorize() + guard access == .authorized else { + busy = false;error = access == .restricted ? "Apple Music access is restricted on this Mac." : "Allow KJMix Pro in System Settings → Privacy & Security → Media & Apple Music.";return + } + do { canPlay = try await client.subscribed();playlists = try await client.playlists() } + catch { self.error = "Apple Music could not connect. Check your internet connection, Apple Music subscription and this app’s MusicKit App Service configuration. \(error.localizedDescription)" } + busy = false + if error == nil { reload() } + } + func reload() { + browseTask?.cancel();generation = UUID();let token = generation + tracks = [];hasMore = false;loadingMore = false + guard access == .authorized else { return } + busy = true;error = nil + let query = query.trimmingCharacters(in:.whitespacesAndNewlines),playlist = playlist + browseTask = Task { [weak self] in + guard let self else { return } + do { + let page = try await client.browse(query:query,playlist:playlist,offset:0) + guard !Task.isCancelled,generation == token else { return } + tracks = page.tracks;hasMore = page.hasMore;busy = false + } catch { guard generation == token,!Task.isCancelled else { return };self.error = error.localizedDescription;busy = false } + } + } + func more() { + guard hasMore,!busy,!loadingMore else { return };loadingMore = true + let token = generation,offset = tracks.count,query = query.trimmingCharacters(in:.whitespacesAndNewlines),playlist = playlist + browseTask = Task { [weak self] in + guard let self else { return } + do { + let page = try await client.browse(query:query,playlist:playlist,offset:offset) + guard !Task.isCancelled,generation == token else { return } + let existing = Set(tracks.map(\.id));tracks += page.tracks.filter{!existing.contains($0.id)};hasMore = page.hasMore;loadingMore = false + } catch { guard generation == token,!Task.isCancelled else { return };self.error = error.localizedDescription;loadingMore = false } + } + } + func play(_ track: AppleMusicTrack) async { + guard canPlay else { error = "An active Apple Music subscription is required for full playback.";return } + do { try await client.play(track.id);observePlayback() } catch { self.error = error.localizedDescription } + } + func enqueue(_ track: AppleMusicTrack) async { + guard canPlay else { error = "An active Apple Music subscription is required for full playback.";return } + do { try await client.enqueue(track.id);observePlayback() } catch { self.error = error.localizedDescription } + } + func toggle() async { + do { if playback.playing { client.pause() } else { try await client.resume() };observePlayback() } catch { self.error = error.localizedDescription } + } + func pause() { client.pause();playback = client.playback } + func stop() { client.stop();playback = client.playback;playbackTask?.cancel();playbackTask = nil } + func seek(_ value: Double) { client.seek(min(playback.duration,max(0,value)));playback = client.playback } + func next() async { do { try await client.next();observePlayback() } catch { self.error = error.localizedDescription } } + private func observePlayback() { + playback = client.playback + guard playbackTask == nil else { return } + playbackTask = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(nanoseconds:250_000_000) } catch { return } + guard let self else { return };self.playback = self.client.playback + } + } + } +} + +@available(macOS 14.0, *) struct AppleMusicBrowser: View { + @ObservedObject var session: AppleMusicSession + @State private var pendingPlay: AppleMusicTrack? + @State private var pendingResume = false + let hasPlayingDeck: () -> Bool + let stopDecks: () -> Void + private func requestPlay(_ track: AppleMusicTrack) { + if hasPlayingDeck() { pendingPlay = track } else { Task { await session.play(track) } } + } + var body: some View { + VStack(spacing:0) { + HStack(spacing:14) { + Label("Apple Music",systemImage:"music.note").font(.title3.bold()).foregroundStyle(.pink) + Picker("Apple Music library",selection:$session.playlist) { + Text("My Library").tag(nil as String?) + ForEach(session.playlists) { Text($0.name).tag(Optional($0.id)) } + }.labelsHidden().frame(maxWidth:200).disabled(session.access != .authorized) + Spacer() + StudioSearchField(placeholder:"Search Apple Music catalog",text:$session.query).frame(maxWidth:320) + .onSubmit { session.reload() } + Button("Search") { session.reload() }.buttonStyle(LibraryActionButtonStyle(tint:.pink)).disabled(session.access != .authorized) + Button(session.access == .authorized ? "Refresh Connection" : "Connect Apple Music") { Task { await session.connect() } } + .buttonStyle(LibraryActionButtonStyle(tint:.pink,prominent:true)).disabled(session.busy) + }.padding(14) + if let error = session.error { HStack { Image(systemName:"exclamationmark.circle.fill").foregroundStyle(.orange);Text(error).font(.callout);Spacer();Button("Dismiss") { session.error = nil } }.padding(12).background(Color.orange.opacity(0.1)) } + if session.busy { ProgressView("Loading Apple Music…").padding() } + if session.access != .authorized { + VStack(spacing:12) { + Image(systemName:"music.note.list").font(.system(size:34)).foregroundStyle(.pink) + Text("Your Apple Music library and playlists").font(.headline) + Text("Connect to browse your library, search the catalog and play through Apple Music.").foregroundStyle(.secondary) + }.frame(maxWidth:.infinity,maxHeight:.infinity) + } else { + Table(session.tracks) { + TableColumn("Track") { track in + HStack(spacing:10) { + AsyncImage(url:track.artwork) { image in image.resizable().scaledToFill() } placeholder: { Image(systemName:"music.note").frame(maxWidth:.infinity,maxHeight:.infinity).background(Color.pink.opacity(0.12)) }.frame(width:32,height:32).clipShape(RoundedRectangle(cornerRadius:5)) + Text(track.title).lineLimit(1) + }.padding(.vertical,3).onTapGesture(count:2) { requestPlay(track) } + .contextMenu { Button("Play in Apple Music") { requestPlay(track) };Button("Add to Apple Music Queue") { Task { await session.enqueue(track) } } } + }.width(min:180,ideal:300) + TableColumn("Artist",value:\.artist).width(min:100,ideal:180) + TableColumn("Album",value:\.album).width(min:100,ideal:180) + TableColumn("Time") { Text(Self.time(track:$0.duration)).monospacedDigit() }.width(60) + TableColumn("Playback") { track in + HStack { Button { requestPlay(track) } label: { Image(systemName:"play.fill") };Button { Task { await session.enqueue(track) } } label: { Image(systemName:"text.badge.plus") } }.disabled(!session.canPlay) + }.width(90) + } + if session.hasMore { Button(session.loadingMore ? "Loading…" : "Load More") { session.more() }.disabled(session.loadingMore).padding(8) } + } + Divider() + AppleMusicTransportBar(session:session,toggle:{ + if hasPlayingDeck(),!session.playback.playing { pendingResume = true } + else { Task { await session.toggle() } } + }) + Text("MusicKit playback uses the system audio output. Deck EQ, sync, loops, waveform analysis and recording apply to local tracks.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,14).padding(.bottom,10) + }.background(Color(red:0.095,green:0.11,blue:0.135)).clipShape(RoundedRectangle(cornerRadius:10)) + .onChange(of:session.playlist) { _ in session.query = "";session.reload() } + .onChange(of:session.query) { value in if value.isEmpty { session.reload() } } + .alert("Stop deck playback?",isPresented:Binding(get:{pendingPlay != nil || pendingResume},set:{if !$0 { pendingPlay = nil;pendingResume = false }})) { + Button("Cancel",role:.cancel) { pendingPlay = nil;pendingResume = false } + Button("Stop Decks & Play") { + stopDecks() + let track = pendingPlay;pendingPlay = nil;pendingResume = false + Task { if let track { await session.play(track) } else { await session.toggle() } } + } + } message: { Text("Apple Music uses its own player. Stop the playing decks before starting Apple Music?") } + } + static func time(track: Double) -> String { guard track.isFinite else { return "0:00" };let seconds = max(0,Int(track));return String(format:"%d:%02d",seconds/60,seconds%60) } +} + +@available(macOS 14.0, *) private struct AppleMusicTransportBar: View { + let session: AppleMusicSession + let toggle: () -> Void + @State private var queueVisible = false + @ObservedObject var transport: AppleMusicTransportState + init(session: AppleMusicSession,toggle: @escaping () -> Void) { self.session = session;self.toggle = toggle;transport = session.transport } + var body: some View { + HStack(spacing:14) { + Button(action:toggle) { Image(systemName:session.playback.playing ? "pause.fill" : "play.fill") }.disabled(session.playback.title.isEmpty) + Button { session.stop() } label: { Image(systemName:"stop.fill") }.disabled(session.playback.title.isEmpty) + Button { Task { await session.next() } } label: { Image(systemName:"forward.end.fill") }.disabled(session.playback.title.isEmpty) + VStack(alignment:.leading,spacing:3) { Text(session.playback.title.isEmpty ? "Choose an Apple Music track" : session.playback.title).fontWeight(.semibold).lineLimit(1);Text(session.playback.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }.frame(minWidth:160,maxWidth:300,alignment:.leading) + Text(AppleMusicBrowser.time(track:session.playback.position)).monospacedDigit() + Slider(value:Binding(get:{min(session.playback.duration,max(0,session.playback.position))},set:{session.seek($0)}),in:0...max(1,session.playback.duration)).tint(.pink).disabled(session.playback.duration <= 0) + Text(AppleMusicBrowser.time(track:session.playback.duration)).monospacedDigit() + Button { queueVisible = true } label: { Label("Queue",systemImage:"text.line.first.and.arrowtriangle.forward") } + }.buttonStyle(.bordered).padding(14) + .sheet(isPresented:$queueVisible) { + VStack(alignment:.leading,spacing:14) { + HStack { Text("Apple Music Queue").font(.title2.bold());Spacer();Button("Done") { queueVisible = false } } + List(transport.value.queue) { item in + HStack { Image(systemName:item.current ? "speaker.wave.2.fill" : "music.note").foregroundStyle(item.current ? .pink : .secondary);VStack(alignment:.leading) { Text(item.title);Text(item.artist).font(.caption).foregroundStyle(.secondary) } } + } + }.padding(22).frame(width:620,height:420).preferredColorScheme(.dark) + } + } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 4ef06f8..64802b3 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -365,6 +365,11 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var error: String? let root: URL private(set) var library: Library? + private var appleMusicStorage: AnyObject? + @available(macOS 14.0, *) var appleMusic: AppleMusicSession { + if let session = appleMusicStorage as? AppleMusicSession { return session } + let session = AppleMusicSession();appleMusicStorage = session;return session + } let dragSession = UUID().uuidString @Published var songs = [Song]() @Published var playlists = [PersonPlace]() @@ -379,6 +384,12 @@ struct DJAudioDevice: Identifiable, Equatable { mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms") a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms")) preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix() + for deck in [a,b] { + deck.player.onWillStartPlayback = { [weak self] in + if #available(macOS 14.0, *),let session = self?.appleMusicStorage as? AppleMusicSession { session.pause() } + return 0 + } + } Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in Task { @MainActor [weak self] in self?.maintainSync() } }.store(in:&syncObservers) @@ -1007,6 +1018,7 @@ struct DJConsole: View { @State private var reinterpreting = false @State private var reinterpretIDs = Set() @State private var editingTrack: Song? + @State private var appleMusicVisible = false @State private var graphicEQVisible = false @State private var waveformDetails = false @State private var favouritesOnly = false @@ -1052,7 +1064,13 @@ struct DJConsole: View { VStack(spacing:10) { header DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) + if appleMusicVisible { + if #available(macOS 14.0, *) { + AppleMusicBrowser(session:system.appleMusic,hasPlayingDeck:{system.a.player.playing || system.b.player.playing},stopDecks:{system.automix.stop();if system.a.player.playing { system.a.player.toggle() };if system.b.player.playing { system.b.player.toggle() }}) + } else { Text("Apple Music playback requires macOS 14 or later.").frame(maxWidth:.infinity,maxHeight:.infinity) } + } else { DJLibrarySnapshot(revision:libraryRevision,selection:selection,selected:selected,page:page,busy:system.generatingWaveforms || system.importing,font:system.settings.libraryFont,height:system.settings.libraryRowHeight) { browser }.equatable().frame(maxWidth:.infinity,maxHeight:.infinity) + } if let progress = system.automaticWaveformProgress { BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) } @@ -1130,6 +1148,7 @@ struct DJConsole: View { Image(systemName:"waveform.path").foregroundStyle(DJStyle.a) Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded)) Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary) + Picker("Music source",selection:$appleMusicVisible) { Text("DJ Library").tag(false);Text("Apple Music").tag(true) }.pickerStyle(.segmented).frame(width:230) Spacer() Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.tint(system.graphicEQ.enabled ? .cyan : .accentColor) DJQueueButton(automix:system.automix,open:{queueVisible = true}) diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index b887bbb..03c2a05 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -51,51 +51,63 @@ import CoreAudio guard let range = scheduledLoop,position >= range.upperBound else { return position } return range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) } - private func exitNativeLoopWithoutRestart() -> Bool { - guard playing,let file,let render = node.lastRenderTime,let clock = node.playerTime(forNodeTime:render) else { return false } - let sourceTime = rawAudioPosition,pending = pendingLoopSwitch - let range = pending.map{sourceTime >= $0.at ? $0.range : scheduledLoop} ?? scheduledLoop - let raw = pending.map{sourceTime >= $0.at ? $0.raw : scheduledRawLoop} ?? scheduledRawLoop - guard let range,let raw,let source = raw.floatChannelData,!raw.format.isInterleaved else { return false } - let rate = file.processingFormat.sampleRate,now = mach_absolute_time() - let elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime))*tempo : 0 - let sample = clock.sampleTime+AVAudioFramePosition(elapsed*rate)+512 - let position = loopPosition(anchor+Double(sample)/rate) - let when = AVAudioTime(sampleTime:sample,atRate:rate) - let offset = max(0,min(Int(raw.frameLength)-1,Int((position-range.lowerBound)*rate))) - let count = min(2048,Int(raw.frameLength)-offset) - guard count > 0,let bridge = AVAudioPCMBuffer(pcmFormat:raw.format,frameCapacity:AVAudioFrameCount(count)),let output = bridge.floatChannelData else { return false } - bridge.frameLength = AVAudioFrameCount(count) - for channel in 0...size) } - anchor = position-Double(sample)/rate;time = position - generation = UUID();let token = generation - nativeLoopActive = false;pendingLoopSwitch = nil;scheduledLoop = nil;scheduledRawLoop = nil - // Interrupt only the scheduled loop, leaving the running node and its clock intact. - let next = min(file.length,AVAudioFramePosition(position*rate)+AVAudioFramePosition(count)) - let finished: AVAudioPlayerNodeCompletionHandler = { [weak self] _ in - Task { @MainActor in - guard let self,self.generation == token else { return } - if self.repeating { self.schedule(at:0,play:true) } - else { self.playing = false;self.time = self.duration;self.timer?.invalidate();self.timer = nil;self.render();self.onFinished?() } + private var loopHandoff: Task? + private var handoffPresentation: (until: UInt64,began: UInt64,position: Double,range: ClosedRange?)? + private func cancelLoopHandoff() { + loopHandoff?.cancel();loopHandoff = nil;handoffPresentation = nil + let other = node === firstNode ? secondNode : firstNode + other.stop();gainUnit(for:other)?.setGain(0,from:0,seconds:0);gainUnit(for:node)?.setGain(1,from:1,seconds:0);node.volume = Float(outputOpacity) + } + /// Prepare the replacement on an idle node, then overlap matching source audio. + /// Never interrupt or flush the node that is currently feeding the output. + private func handoffLoopAudio(at position: Double) { + cancelLoopHandoff() + let outgoing = node,outgoingGain = gainUnit(for:node) + let began = mach_absolute_time() + let lead = loopEnabled ? 0.05 : min(0.05,max(0,(duration-position)/max(0.01,tempo)*0.25)) + let host = began+AVAudioTime.hostTime(forSeconds:lead) + let previousRange = nativeLoopActive ? scheduledLoop : nil + var target = position+lead*tempo + if loopEnabled,nativeLoopActive,let range = scheduledLoop,target >= range.upperBound { + target = range.lowerBound+(target-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) + } else if loopEnabled,let range = preparedLoop,target >= range.upperBound { + target = range.lowerBound+(target-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) + } + node = outgoing === firstNode ? secondNode : firstNode + schedule(at:target,play:false,notifyPlayback:false,cancelHandoff:false) + time = position + let incoming = node,incomingGain = gainUnit(for:node) + incoming.volume = Float(outputOpacity) + incomingGain?.setGain(1,from:0,seconds:0.012,at:host) + outgoingGain?.setGain(0,seconds:0.012,at:host) + incoming.play(at:AVAudioTime(hostTime:host));playing = true + handoffPresentation = (host,began,position,previousRange) + loopHandoff = Task { @MainActor [weak self] in + // Wait for the envelopes to finish on the audio render thread, rather + // than assuming a UI timer ran in time for a hardware render quantum. + while outgoingGain?.settled == false || incomingGain?.settled == false { + do { try await Task.sleep(nanoseconds:5_000_000) } catch { return } } + guard !Task.isCancelled else { return } + outgoing.stop() + guard let self else { return } + incoming.volume = Float(outputOpacity) + loopHandoff = nil;handoffPresentation = nil } - if next < file.length { - node.scheduleBuffer(bridge,at:when,options:.interrupts,completionHandler:nil) - node.scheduleSegment(file,startingFrame:next,frameCount:AVAudioFrameCount(min(file.length-next,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack,completionHandler:finished) - } else { - node.scheduleBuffer(bridge,at:when,options:.interrupts,completionCallbackType:.dataPlayedBack,completionHandler:finished) - } - startTimer();self.render();return true + startTimer() + } + private func exitNativeLoopWithoutRestart() -> Bool { + guard playing,file != nil else { return false } + let position = displayTime + handoffLoopAudio(at:position) + return true } private func activatePreparedLoop() -> Bool { guard playing,let range = preparedLoop,preparedRawLoop != nil,loopBuffer != nil else { return false } let position = displayTime guard position >= range.lowerBound else { return false } let handoff = position < range.upperBound ? position : range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) - // File commands cannot be interrupted by scheduling a PCM buffer. Replace - // the node's commands once, with all loop audio already prepared. Keep the - // engine, effects and output route running throughout the handoff. - schedule(at:handoff,play:true,notifyPlayback:false) + handoffLoopAudio(at:handoff) return nativeLoopActive } private func prepareNativeLoop() { @@ -157,6 +169,11 @@ import CoreAudio var canJog: Bool { currentSongID != nil && duration > 0 } /// Read the audio clock for display frames without publishing the entire player at 60 Hz. var displayTime: Double { + if playing,let pending = handoffPresentation,mach_absolute_time() < pending.until { + var value = pending.position+AVAudioTime.seconds(forHostTime:mach_absolute_time()-pending.began)*tempo + if let range = pending.range,value >= range.upperBound { value = range.lowerBound+(value-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) } + return min(duration,max(0,value)) + } guard playing,node.isPlaying,let file,let render = node.lastRenderTime, let current = node.playerTime(forNodeTime:render) else { return time } let now = mach_absolute_time() @@ -178,7 +195,7 @@ import CoreAudio cancelPendingPlayback() if slideImage != nil,playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart);slideAnchor = time } else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,loopPosition(anchor+Double(current.sampleTime)/file.processingFormat.sampleRate))) } - node.pause();playing = false;timer?.invalidate();timer = nil + cancelLoopHandoff();node.pause();playing = false;timer?.invalidate();timer = nil } func hotCue(_ index: Int,clear: Bool = false) { guard hotCues.indices.contains(index),canJog else { return } @@ -226,7 +243,7 @@ import CoreAudio } } } - private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;node.volume = Float(next) } } + private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;if loopHandoff == nil { node.volume = Float(next) } } } private var videoPlayer: AVPlayer? private var videoOutput: AVPlayerItemVideoOutput? private let videoContext = CIContext() @@ -249,7 +266,7 @@ import CoreAudio tick() let running = engine.isRunning,resume = playing,position = time,wasWaiting = waitingForBackground var previousMap = outputChannelMap() - cancelPendingPlayback();generation = UUID();node.stop();engine.stop() + cancelLoopHandoff();cancelPendingPlayback();generation = UUID();node.stop();engine.stop() do { var value = device let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value))) @@ -339,7 +356,15 @@ import CoreAudio private(set) var currentSongID: Int64? var onFinished: (() -> Void)? private let engine = AVAudioEngine() - private let node = AVAudioPlayerNode() + private let firstNode = AVAudioPlayerNode() + private let secondNode = AVAudioPlayerNode() + private lazy var node = firstNode + private let transportMixer = AVAudioMixerNode() + private let firstEnvelope = AVAudioUnitEffect(audioComponentDescription:DJTransportEnvelopeUnit.component) + private let secondEnvelope = AVAudioUnitEffect(audioComponentDescription:DJTransportEnvelopeUnit.component) + private func gainUnit(for node: AVAudioPlayerNode) -> DJTransportEnvelopeUnit? { + (node === firstNode ? firstEnvelope : secondEnvelope).auAudioUnit as? DJTransportEnvelopeUnit + } private let effect = AVAudioUnitTimePitch() private var file: AVAudioFile? private var anchor: Double = 0 @@ -350,7 +375,7 @@ import CoreAudio init(waveformCacheDirectory: URL? = nil, persistsMix: Bool = true) { self.persistsMix = persistsMix self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms") - engine.attach(node);engine.attach(effect);engine.attach(tone);engine.attach(graphicEQ);engine.attach(vocalReduction);graphicEQ.bypass = true + engine.attach(firstNode);engine.attach(secondNode);engine.attach(transportMixer);engine.attach(firstEnvelope);engine.attach(secondEnvelope);engine.attach(effect);engine.attach(tone);engine.attach(graphicEQ);engine.attach(vocalReduction);graphicEQ.bypass = true if persistsMix,let data = try? Data(contentsOf:mixURL),let mix = try? JSONDecoder().decode(GlobalMix.self,from:data) { volume = min(1,max(0,mix.volume));lowGain = min(12,max(-12,mix.low));midGain = min(12,max(-12,mix.mid));highGain = min(12,max(-12,mix.high));filter = min(1,max(-1,mix.filter));toneEnabled = mix.enabled } @@ -370,7 +395,11 @@ import CoreAudio let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } - engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(firstNode,to:firstEnvelope,format:loaded.processingFormat) + engine.connect(secondNode,to:secondEnvelope,format:loaded.processingFormat) + engine.connect(firstEnvelope,to:transportMixer,fromBus:0,toBus:0,format:loaded.processingFormat) + engine.connect(secondEnvelope,to:transportMixer,fromBus:0,toBus:1,format:loaded.processingFormat) + engine.connect(transportMixer,to:effect,format:loaded.processingFormat) engine.connect(effect,to:tone,format:loaded.processingFormat) engine.connect(tone,to:graphicEQ,format:loaded.processingFormat) engine.connect(graphicEQ,to:vocalReduction,format:loaded.processingFormat) @@ -436,7 +465,8 @@ import CoreAudio self.onPlaybackReady?();self.node.play() } } - private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true) { + private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true,cancelHandoff: Bool = true) { + if cancelHandoff { cancelLoopHandoff() } guard let file else { return } cancelPendingPlayback();generation = UUID(); let token = generation node.stop();audioScheduleRestartCount += 1;if !fading { setFade(1) } @@ -446,9 +476,23 @@ import CoreAudio guard start < file.length else { playing = false; render(); return } nativeLoopActive = false;pendingLoopSwitch = nil;scheduledLoop = nil;scheduledRawLoop = nil if loopEnabled,let range = preparedLoop,range.lowerBound == loopIn,range.upperBound == loopOut,let loopBuffer,seconds < range.upperBound { - let endFrame = AVAudioFramePosition(range.upperBound*rate) - let remainder = AVAudioFrameCount(max(1,endFrame-start)) - node.scheduleSegment(file,startingFrame:start,frameCount:remainder,at:nil) + let first = AVAudioFramePosition(range.lowerBound*rate) + if start < first { + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(first-start),at:nil) + } else { + // The first partial pass must use the same seam treatment as later + // passes; a raw file prefix would click at the first wrap only. + let offset = min(Int(loopBuffer.frameLength)-1,max(0,Int(start-first))) + let count = Int(loopBuffer.frameLength)-offset + if let tail = AVAudioPCMBuffer(pcmFormat:loopBuffer.format,frameCapacity:AVAudioFrameCount(count)), + let input = loopBuffer.floatChannelData,let output = tail.floatChannelData { + tail.frameLength = AVAudioFrameCount(count) + for channel in 0...size) + } + node.scheduleBuffer(tail,at:nil) + } + } node.scheduleBuffer(loopBuffer,at:nil,options:.loops) nativeLoopActive = true;scheduledLoop = range;scheduledRawLoop = preparedRawLoop if play { if notifyPlayback { startNodePlayback() } else { node.play() } } @@ -479,11 +523,12 @@ import CoreAudio if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return } guard file != nil else { return } - if playing { cancelPendingPlayback();tick(); node.pause(); playing = false } + if playing { cancelPendingPlayback();tick();cancelLoopHandoff(); node.pause(); playing = false } else if time >= duration { schedule(at:0,play:true); startTimer() } else { startNodePlayback(); playing = true; startTimer() } } func stop() { + cancelLoopHandoff() endScrubbing() cancelPendingPlayback() graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1 @@ -495,7 +540,7 @@ import CoreAudio generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } meter.reset(); leftLevel = 0; rightLevel = 0 - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone);engine.disconnectNodeOutput(graphicEQ);engine.disconnectNodeOutput(vocalReduction) + engine.disconnectNodeOutput(firstNode);engine.disconnectNodeOutput(secondNode);engine.disconnectNodeOutput(transportMixer);engine.disconnectNodeOutput(firstEnvelope);engine.disconnectNodeOutput(secondEnvelope); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone);engine.disconnectNodeOutput(graphicEQ);engine.disconnectNodeOutput(vocalReduction) file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" } var audibleScrubbing = false @@ -504,6 +549,8 @@ import CoreAudio private var lastScrubAudio = -Double.infinity private var scrubTarget: Double? var scrubOutputLevel: Float { let levels = meter.snapshot();return max(levels.0,levels.1) } + func beginAudioContinuityCheck() { meter.beginContinuityCheck() } + var audioContinuity: (silentFrames: Int,largestStep: Float) { meter.continuity } /// Preview short grains through the deck's normal EQ, fader and output route. /// Keep transport paused so scrubbing cannot start Automix or change sync state. func scrub(to value: Double) { @@ -590,8 +637,8 @@ import CoreAudio let levels = meter.snapshot() let left = playing ? levels.0 : 0,right = playing ? levels.1 : 0 if leftLevel != left { leftLevel = left };if rightLevel != right { rightLevel = right } - if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - let next = min(duration,max(0,loopPosition(anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate))) + if playing,node.lastRenderTime != nil { + let next = displayTime if abs(time-next) > 0.0001 { time = next } if !nativeLoopActive,loopEnabled,loopBuffer != nil { _ = activatePreparedLoop() } if !nativeLoopActive,loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return } @@ -706,6 +753,17 @@ enum AudioTone { final class AudioLevelMeter: @unchecked Sendable { private let lock = NSLock() private var levels: (Float,Float) = (0,0) + private var checkingContinuity = false + private var lastSample: Float? + private var silentFrames = 0, longestSilence = 0 + private var largestStep: Float = 0 + func beginContinuityCheck() { + lock.lock();defer { lock.unlock() } + checkingContinuity = true;lastSample = nil;silentFrames = 0;longestSilence = 0;largestStep = 0 + } + var continuity: (silentFrames: Int,largestStep: Float) { + lock.lock();defer { lock.unlock() };return (longestSilence,largestStep) + } func consume(_ buffer: AVAudioPCMBuffer) { guard let data = buffer.floatChannelData else { return } let channels = Int(buffer.format.channelCount), frames = Int(buffer.frameLength) @@ -713,7 +771,17 @@ final class AudioLevelMeter: @unchecked Sendable { for i in 0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } func reset() { lock.lock();levels = (0,0);lock.unlock() } @@ -799,3 +867,68 @@ final class DJVocalReductionUnit: AUAudioUnit { } } } + +/// Gain envelopes advance per audio frame; the main thread never performs the fade. +private final class DJEnvelopeCommand: @unchecked Sendable { + private let lock = NSLock() + private var revision: UInt64 = 0,completed: UInt64 = 0 + private var target: Float = 0,initial: Float? + private var seconds: Double = 0 + private var host: UInt64 = 0 + func set(_ target: Float,from initial: Float?,seconds: Double,host: UInt64) { + lock.lock();defer { lock.unlock() } + revision &+= 1;self.target = target;self.initial = initial;self.seconds = seconds;self.host = host + } + func read(after version: UInt64) -> (UInt64,Float,Float?,Double,UInt64)? { + guard lock.try() else { return nil };defer { lock.unlock() } + guard revision != version else { return nil };return (revision,target,initial,seconds,host) + } + func finish(_ version: UInt64) { guard lock.try() else { return };completed = version;lock.unlock() } + var settled: Bool { lock.lock();defer { lock.unlock() };return completed == revision } +} +final class DJTransportEnvelopeUnit: AUAudioUnit { + private var inputs: AUAudioUnitBusArray!,outputs: AUAudioUnitBusArray! + private let command = DJEnvelopeCommand() + static let component: AudioComponentDescription = { + let value = AudioComponentDescription(componentType:kAudioUnitType_Effect,componentSubType:0x6B6A656E,componentManufacturer:0x4B4A4D58,componentFlags:0,componentFlagsMask:0) + AUAudioUnit.registerSubclass(DJTransportEnvelopeUnit.self,as:value,name:"KJMix: Transport Envelope",version:1);return value + }() + override init(componentDescription: AudioComponentDescription,options: AudioComponentInstantiationOptions = []) throws { + try super.init(componentDescription:componentDescription,options:options) + let format = AVAudioFormat(standardFormatWithSampleRate:44100,channels:2)! + inputs = AUAudioUnitBusArray(audioUnit:self,busType:.input,busses:[try AUAudioUnitBus(format:format)]) + outputs = AUAudioUnitBusArray(audioUnit:self,busType:.output,busses:[try AUAudioUnitBus(format:format)]) + maximumFramesToRender = 4096 + } + override var inputBusses: AUAudioUnitBusArray { inputs } + override var outputBusses: AUAudioUnitBusArray { outputs } + func setGain(_ value: Float,from initial: Float? = nil,seconds: Double,at host: UInt64 = 0) { command.set(value,from:initial,seconds:seconds,host:host) } + var settled: Bool { command.settled } + override var internalRenderBlock: AUInternalRenderBlock { + let command = self.command,rate = outputBusses[0].format.sampleRate + var version: UInt64 = 0,startHost: UInt64 = 0,gain: Float = 0,target: Float = 0,step: Float = 0,remaining = 0 + let ticksPerFrame = Double(AVAudioTime.hostTime(forSeconds:1))/rate + return { flags,time,frames,_,data,_,pull in + guard let pull else { return kAudioUnitErr_NoConnection } + let result = pull(flags,time,frames,0,data) + guard result == noErr else { return result } + if let next = command.read(after:version) { + version = next.0;target = next.1;startHost = next.4 + if let initial = next.2 { gain = initial } + remaining = Int(next.3*rate);step = remaining > 0 ? (target-gain)/Float(remaining) : 0 + if remaining == 0 { gain = target } + } + let buffers = UnsafeMutableAudioBufferListPointer(data) + for frame in 0.. 0,(startHost == 0 || time.pointee.mHostTime+UInt64(Double(frame)*ticksPerFrame) >= startHost) { gain += step;remaining -= 1;if remaining == 0 { gain = target } } + for buffer in buffers { + guard let values = buffer.mData?.assumingMemoryBound(to:Float.self) else { continue } + let channels = Int(buffer.mNumberChannels) + for channel in 0.. AppleMusicAccess { authorizationRequests += 1;access = granted;return granted } + func subscribed() async throws -> Bool { subscription } + func playlists() async throws -> [AppleMusicPlaylist] { [.init(id:"playlist",name:"My playlist")] } + func browse(query: String,playlist: String?,offset: Int) async throws -> AppleMusicPage { + browseRequests += 1 + if query == "slow" { try? await Task.sleep(nanoseconds:100_000_000) } + let id = query.isEmpty ? "library" : query + let track = AppleMusicTrack(id:id,title:id,artist:"Artist",album:"Album",duration:180,artwork:nil) + return .init(tracks:offset == 0 ? [track] : [track,.init(id:"next",title:"Next",artist:"Artist",album:"Album",duration:180,artwork:nil)],hasMore:offset == 0) + } + func play(_ id: String) async throws { played.append(id);playback = .init(title:id,artist:"Artist",playing:true,position:0,duration:180) } + func enqueue(_ id: String) async throws { } + func resume() async throws { playback.playing = true } + func pause() { playback.playing = false } + func stop() { playback.playing = false;playback.position = 0 } + func seek(_ seconds: Double) { playback.position = seconds } + func next() async throws { } +} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index ca1d557..6036ac6 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -110,13 +110,13 @@ Ordinary loops are prepared on a worker and scheduled with the audio node's loop The waveform/BPM admission limit is shared across imports, on-load analysis and batch jobs, so separate analysis paths cannot each start their own pair of workers. Playing audio reduces that global limit to one; reads yield between blocks. Neutral time-pitch processing is bypassed. -Loop Off cancels scheduled repeating audio immediately and resumes normal playback at the current loop position. The TV/video view presents original decoded frames directly in a display-resolution-backed layer with aspect-fit scaling, and opens at 1280×720 by default. CDG source graphics remain their original fixed resolution; scaling cannot recover missing HD detail. Settings uses a non-wrapping studio selector; the Karaoke Studio toolbar omits the Settings button. +Loop Off returns to normal file playback through the audio-clock handoff described below. The TV/video view presents original decoded frames directly in a display-resolution-backed layer with aspect-fit scaling, and opens at 1280×720 by default. CDG source graphics remain their original fixed resolution; scaling cannot recover missing HD detail. Settings uses a non-wrapping studio selector; the Karaoke Studio toolbar omits the Settings button. ## External DJ storage DJ Settings → Library → Move Media and Waveforms selects a destination folder, including an external drive. Imported audio is stored in its Media subfolder and saved waveforms in Waveforms. Transfers run on a utility worker with progress and cancellation; both decks and headphone preview must be stopped. Every copied file is verified before its original is removed. Waveform keys are updated for relocated audio, so saved analysis remains reusable. Folder access is remembered across launches; missing external drives are reported instead of silently creating substitute folders. The database, playlists, favourites, ratings, MIDI setup and settings stay on this Mac. Cancellation retains verified transfers and remaining originals; rerun the move to finish an interrupted transfer. -Changes to a playing loop's length are queued with the audio node at a loop boundary, avoiding a node restart. A three-millisecond seam blend reduces edge clicks without changing the loop's frame count. Loop Off still cancels repetition immediately. +Changes to a playing loop's length are queued with the audio node at a loop boundary, avoiding a node restart. A three-millisecond seam blend reduces edge clicks without changing the loop's frame count. Loop Off switches back to the source file through the same overlap. Move progress is presented by the DJ settings pane that observes the move directly. Discovery reports progress before file matching begins, skips empty cache folders and checks each audio stamp once. Cancel responds during scanning, hashing and streaming copy; copies use 1 MB chunks and remove incomplete staging files. Large file verification/copy phases report byte counts instead of leaving the interface unchanged. @@ -128,8 +128,11 @@ Right-click selected DJ tracks to remove their library entries. DJ Settings → Both library toolbars share coloured, rounded action buttons and matching search fields. DJ Library groups its heading/selection/actions above filters/search/page navigation: cyan Import, green Add to Queue, purple Playlists and amber Favourites. Filter/library changes prune stale selections and clamp pagination to the current results. -Loop Off continues from a short unmodified PCM bridge into the remainder of the source file without stopping the running audio node. This avoids the stop/reschedule gap while keeping the audio clock and displayed position continuous. -Activating a loop during playback now replaces the remaining file command with a decoded continuation and the repeat buffer, without stopping the audio node. Future loops are primed until playback reaches their range. Loop Off also preserves the normal end-of-track callback when only the continuation buffer remains. -Live loop activation replaces the running file command once with the prepared loop while keeping the audio engine, effects and output route running. Repetition then runs on the audio clock; it does not seek on each UI update. Audio-output regression tests check the repeated signal, length changes and return to the file after Loop Off. + +Loop activation and cancellation prepare an idle transport node while the audible node keeps running. Both feed the same effects chain. A shared host-clock start (up to 50 ms lookahead) and per-sample 12 ms complementary envelopes overlap the streams before retiring the outgoing node. The first partial loop pass uses the same processed seam as subsequent passes. Regression checks measure silent runs and adjacent-sample jumps across repeated start/stop operations, including two playing decks and changed tempos, as well as actual loop content and natural end-of-track completion. + +Apple Music is available in DJ Studio’s music-source selector on macOS 14 or later. Connect uses native MusicKit authorisation; browse My Library and playlists, search the catalog, load further results, play, pause, stop, seek, skip and enqueue. Starting Apple Music while decks are playing asks before stopping deck playback and Automix. Nothing streams automatically at launch. MusicKit handles user tokens; no private keys or subscriber credentials are stored by KJMix Pro. + +Enable the MusicKit App Service for the existing app identifier `com.kjamsclone.app` in the Apple Developer account, and run a signed build. The project includes `NSAppleMusicUsageDescription`; a subscription and network access are required for catalog playback. The unsigned verification build cannot validate developer-token generation or subscriber streaming. Public MusicKit uses Apple’s single application player and system audio output, not the local two-deck DSP graph: EQ/filter, crossfader, cue routing, sync, loops, waveform generation and recording of protected streamed audio are not provided. Apple-approved DJ SDK integration would require its separate documentation/access. See [Apple MusicKit](https://developer.apple.com/musickit/) and [ApplicationMusicPlayer](https://developer.apple.com/documentation/musickit/applicationmusicplayer).