removed unwanted bar now with waveforms, major updates and enhancements

This commit is contained in:
Mark Gregory
2026-11-06 14:47:53 +00:00
parent f9655503df
commit c39c2bd757
6 changed files with 649 additions and 89 deletions
+34 -28
View File
@@ -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 = "<group>"; };
B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = "<group>"; };
B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = "<group>"; };
B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = "<group>"; };
B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = "<group>"; };
B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = "<group>"; };
B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = "<group>"; };
000000000000000000000001 /* KJMixProApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KJMixProApp.swift; sourceTree = "<group>"; };
000000000000000000000002 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = "<group>"; };
000000000000000000000003 /* CDG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CDG.swift; sourceTree = "<group>"; };
@@ -62,6 +56,14 @@
AE0000000000000000000001 /* LibraryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryScanner.swift; sourceTree = "<group>"; };
AE0000000000000000000010 /* DJWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkspace.swift; sourceTree = "<group>"; };
AF0000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = "<group>"; };
B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = "<group>"; };
B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = "<group>"; };
B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = "<group>"; };
B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = "<group>"; };
B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = "<group>"; };
B60000000000000000000002 /* DJNativeDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJNativeDragDrop.swift; sourceTree = "<group>"; };
B70000000000000000000002 /* AppleMusic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusic.swift; sourceTree = "<group>"; };
/* 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;
+295
View File
@@ -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<MusicKit.Playlist>();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<MusicKit.Song>();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<Void,Never>?
private var playbackTask: Task<Void,Never>?
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)
}
}
}
+19
View File
@@ -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<Int64>()
@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})
+185 -52
View File
@@ -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..<Int(raw.format.channelCount) { memcpy(output[channel],source[channel].advanced(by:offset),count*MemoryLayout<Float>.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<Void,Never>?
private var handoffPresentation: (until: UInt64,began: UInt64,position: Double,range: ClosedRange<Double>?)?
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..<Int(loopBuffer.format.channelCount) {
memcpy(output[channel],input[channel].advanced(by:offset),count*MemoryLayout<Float>.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..<frames {
left = max(left,abs(data[0][i]));right = max(right,abs(data[min(1,channels-1)][i]))
}
lock.lock();levels = (left,right);lock.unlock()
lock.lock();levels = (left,right)
if checkingContinuity {
for i in 0..<frames {
let sample = data[0][i]
if let lastSample { largestStep = max(largestStep,abs(sample-lastSample)) }
lastSample = sample
silentFrames = abs(sample) < 0.000001 ? silentFrames+1 : 0
longestSilence = max(longestSilence,silentFrames)
}
}
lock.unlock()
}
func snapshot() -> (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..<Int(frames) {
if remaining > 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..<channels { values[frame*channels+channel] *= gain }
}
}
if remaining == 0 { command.finish(version) }
return noErr
}
}
}
+108 -4
View File
@@ -2867,7 +2867,7 @@ extension KJamsCloneTests {
XCTAssertTrue(player.nativeLoopActive);XCTAssertLessThan(player.displayTime,0.301)
let exit = player.displayTime,restartsOnExit = player.audioScheduleRestartCount
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restartsOnExit,"Loop Off must not stop and reschedule the running audio node")
XCTAssertEqual(player.audioScheduleRestartCount,restartsOnExit+1,"Loop Off prepares the idle node before retiring the audible node")
XCTAssertFalse(player.nativeLoopActive);XCTAssertTrue(player.playing)
XCTAssertEqual(player.time,exit,accuracy:0.03)
try await Task.sleep(nanoseconds:180_000_000)
@@ -3070,7 +3070,7 @@ extension KJamsCloneTests {
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertGreaterThanOrEqual(player.displayTime,start);XCTAssertLessThan(player.displayTime,start+0.301)
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restarts+1,"Repeating and cancelling must not replace the node commands again")
XCTAssertEqual(player.audioScheduleRestartCount,restarts+2,"Cancellation prepares the idle node without stopping audible output")
XCTAssertTrue(player.audioPlaybackStarted)
}
@@ -3094,7 +3094,7 @@ extension KJamsCloneTests {
var finished = false;player.onFinished = { finished = true }
let restarts = player.audioScheduleRestartCount
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restarts)
XCTAssertEqual(player.audioScheduleRestartCount,restarts+1)
for _ in 0..<200 where !finished { try await Task.sleep(nanoseconds:5_000_000) }
XCTAssertTrue(finished);XCTAssertFalse(player.playing)
}
@@ -3140,7 +3140,111 @@ extension KJamsCloneTests {
player.loopEnabled = false
try await Task.sleep(nanoseconds:800_000_000)
XCTAssertGreaterThan(player.scrubOutputLevel,0.4,"Loop Off must resume the actual file beyond the loop")
XCTAssertEqual(player.audioScheduleRestartCount,handoffs,"Resizing and cancellation must keep the node running")
XCTAssertEqual(player.audioScheduleRestartCount,handoffs+1,"Cancellation prepares the idle node; resizing reuses the active loop")
}
@MainActor func testLoopHandoffsKeepContinuousAudioAcrossRepeatedStartAndStop() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2))
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:441000));buffer.frameLength = 441000
let channels = try XCTUnwrap(buffer.floatChannelData)
for i in 0..<441000 {
let sample = Float(sin(Double(i)*2 * .pi*440/44100))*0.25
channels[0][i] = sample;channels[1][i] = sample
}
let url = root.appendingPathComponent("Continuous.wav")
do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) }
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Cache"),persistsMix:false)
defer { player.stop() }
player.autoFade = false;player.volume = 0.8
try player.load(Song(id:1,title:"Continuous",audio:url.path,cdg:nil,rating:0,plays:0))
let other = Player(waveformCacheDirectory:root.appendingPathComponent("OtherCache"),persistsMix:false)
defer { other.stop() };other.autoFade = false;other.volume = 0.3
try other.load(Song(id:2,title:"Other deck",audio:url.path,cdg:nil,rating:0,plays:0))
try await Task.sleep(nanoseconds:200_000_000)
player.beginAudioContinuityCheck()
for iteration in 0..<4 {
if iteration == 2 { player.tempo = 1.12;other.tempo = 0.95 }
let start = player.displayTime
player.defineLoop(start:start,end:start+0.307)
for _ in 0..<200 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:5_000_000) }
XCTAssertTrue(player.nativeLoopActive)
try await Task.sleep(nanoseconds:50_000_000)
XCTAssertLessThan(player.audioContinuity.silentFrames,128);XCTAssertLessThan(player.audioContinuity.largestStep,0.08)
player.beginAudioContinuityCheck()
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertLessThan(player.audioContinuity.silentFrames,128);XCTAssertLessThan(player.audioContinuity.largestStep,0.08)
player.beginAudioContinuityCheck()
player.loopEnabled = false
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertLessThan(player.audioContinuity.silentFrames,128);XCTAssertLessThan(player.audioContinuity.largestStep,0.08)
XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted)
XCTAssertGreaterThan(player.scrubOutputLevel,0.05)
}
XCTAssertLessThan(player.audioContinuity.silentFrames,128,"Loop handoffs must not insert a silent audio block")
XCTAssertLessThan(player.audioContinuity.largestStep,0.08,"Loop handoffs must not introduce a hard sample discontinuity")
}
@MainActor func testAppleMusicAuthorizationAndSubscriptionGuardPlayback() async throws {
guard #available(macOS 14.0, *) else { throw XCTSkip("MusicKit playback requires macOS 14") }
let client = TestAppleMusicClient(),session = AppleMusicSession(client:client)
XCTAssertEqual(client.authorizationRequests,0)
client.granted = .denied;await session.connect()
XCTAssertEqual(session.access,.denied);XCTAssertNotNil(session.error);XCTAssertEqual(client.browseRequests,0)
client.granted = .authorized;client.subscription = false;await session.connect()
for _ in 0..<100 where session.busy { try await Task.sleep(nanoseconds:1_000_000) }
let track = try XCTUnwrap(session.tracks.first)
await session.play(track);XCTAssertTrue(client.played.isEmpty);XCTAssertNotNil(session.error)
client.subscription = true;await session.connect()
for _ in 0..<100 where session.busy { try await Task.sleep(nanoseconds:1_000_000) }
await session.play(track);XCTAssertEqual(client.played,[track.id]);XCTAssertTrue(session.playback.playing)
session.seek(1000);XCTAssertEqual(client.playback.position,track.duration)
session.stop();XCTAssertFalse(session.playback.playing)
}
@MainActor func testAppleMusicSearchRejectsStaleResultsAndPaginatesWithoutDuplicates() async throws {
guard #available(macOS 14.0, *) else { throw XCTSkip("MusicKit playback requires macOS 14") }
let client = TestAppleMusicClient(),session = AppleMusicSession(client:client)
session.query = "slow";session.reload();try await Task.sleep(nanoseconds:5_000_000)
session.query = "latest";session.reload();try await Task.sleep(nanoseconds:150_000_000)
XCTAssertEqual(session.tracks.map(\.id),["latest"])
session.more()
for _ in 0..<100 where session.loadingMore { try await Task.sleep(nanoseconds:1_000_000) }
XCTAssertEqual(session.tracks.map(\.id),["latest","next"]);XCTAssertFalse(session.hasMore);XCTAssertTrue(client.played.isEmpty)
let view = NSHostingView(rootView:AppleMusicBrowser(session:session,hasPlayingDeck:{false},stopDecks:{}).frame(width:1100,height:420))
view.frame = NSRect(x:0,y:0,width:1100,height:420);view.layoutSubtreeIfNeeded()
let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds));view.cacheDisplay(in:view.bounds,to:bitmap)
let png = try XCTUnwrap(bitmap.representation(using:.png,properties:[:]))
XCTContext.runActivity(named:"Apple Music browser layout") { activity in
let attachment = XCTAttachment(data:png,uniformTypeIdentifier:"public.png");attachment.lifetime = .keepAlways;activity.add(attachment)
}
}
}
@available(macOS 14.0, *) @MainActor private final class TestAppleMusicClient: AppleMusicClient {
var access: AppleMusicAccess = .authorized
var granted: AppleMusicAccess = .authorized
var subscription = true
var authorizationRequests = 0,browseRequests = 0
var played = [String]()
var playback = AppleMusicPlayback()
func authorize() async -> 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 { }
}
+8 -5
View File
@@ -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).