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
+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
}
}
}