removed unwanted bar now with waveforms, major updates

This commit is contained in:
Mark Gregory
2026-11-06 13:51:45 +00:00
parent 2dc3c96ef3
commit 50e7fb358a
6 changed files with 184 additions and 30 deletions
+16 -2
View File
@@ -195,6 +195,7 @@ struct DJAudioDevice: Identifiable, Equatable {
guard !ids.isEmpty else { return }
guard ![a.player,b.player,preview].contains(where:{$0.playing && ($0.currentSongID.map{ids.contains($0)} ?? false)}) else { error = "Stop the affected deck before removing its track.";return }
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
automaticWaveforms.cancel()
storageInSettings = fromSettings
automix.stop("Library update")
for deck in [a,b] where deck.song.map({ids.contains($0.id)}) == true { deck.unload() }
@@ -267,6 +268,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func moveStorage(to destination: URL) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
automaticWaveforms.cancel()
a.unload();b.unload();stopPreview();storageInSettings = true;storageAction = nil;movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…")
storageTask = Task.detached(priority:.utility) { [weak self] in
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
@@ -302,6 +304,14 @@ struct DJAudioDevice: Identifiable, Equatable {
guard let gains = DJGraphicEQSettings.presets.first(where:{$0.0 == name})?.1 else { return }
graphicEQ = .init(enabled:true,preset:name,gains:gains)
}
@Published var automaticWaveformProgress: WaveformJobProgress?
let automaticWaveforms = PostImportWaveformQueue()
private func queueImportedWaveforms(_ ids: Set<Int64>) {
automaticWaveforms.enqueue(songs.filter{ids.contains($0.id)},locations:referenceLocations,cache:waveformDirectory,update:{ [weak self] in self?.automaticWaveformProgress = $0 },completed:{ [weak self] tracks in
guard let self else { return }
for deck in [self.a,self.b] where tracks.contains(where:{$0.id == deck.song?.id}) { deck.player.refreshCachedWaveform() }
})
}
@Published private(set) var waveformJob: WaveformJobProgress?
@Published private(set) var generatingWaveforms = false
private var waveformTask: Task<Void,Never>?
@@ -408,7 +418,7 @@ struct DJAudioDevice: Identifiable, Equatable {
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,cache = waveformDirectory,copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
@@ -423,7 +433,7 @@ struct DJAudioDevice: Identifiable, Equatable {
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
}
}
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,waveformCache:cache).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
}
@@ -436,6 +446,7 @@ struct DJAudioDevice: Identifiable, Equatable {
self.perform { try self.refresh() }
if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } }
else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) }
if !result.cancelled { self.queueImportedWaveforms(Set(loadedIDs).union(self.songs.filter{!beforeIDs.contains($0.id)}.map(\.id))) }
self.continueFileLoads()
}
}
@@ -1042,6 +1053,9 @@ struct DJConsole: View {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
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})
}
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
+32 -4
View File
@@ -87,6 +87,13 @@ struct SongDragReference: Codable, Sendable {
var playingIntermission = false
@Published var error: String?
@Published var creating: EntityKind?
@Published var automaticWaveformProgress: WaveformJobProgress?
let automaticWaveforms = PostImportWaveformQueue()
private func queueImportedWaveforms(_ ids: Set<Int64>) {
automaticWaveforms.enqueue(songs.filter{ids.contains($0.id)},locations:mediaLocations,cache:root.appendingPathComponent("Waveforms"),update:{ [weak self] in self?.automaticWaveformProgress = $0 },completed:{ [weak self] tracks in
guard let self,let id = self.player.currentSongID,tracks.contains(where:{$0.id == id}) else { return };self.player.refreshCachedWaveform()
})
}
@Published private(set) var waveformJob: WaveformJobProgress?
@Published private(set) var generatingWaveforms = false
private var waveformJobTask: Task<Void,Never>?
@@ -476,15 +483,15 @@ struct SongDragReference: Codable, Sendable {
func importFiles(_ urls: [URL]) {
guard !isImporting,scanningSource == nil,!generatingWaveforms,!movingMedia,!namesBusy else { error = "Wait for the current import, scan or waveform job to finish first.";return }
guard !urls.isEmpty else { return }
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,cache = root.appendingPathComponent("Waveforms")
let token = UUID(),database = root.appendingPathComponent("Library.sqlite"),media = managedMediaDirectory,beforeIDs = Set(songs.map(\.id))
importToken = token;isImporting = true;importProgress = ImportProgress()
importTask = Task.detached(priority:.utility) { [weak self] in
let accessed = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in accessed { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
var latest = ImportProgress(),importedIDs = [Int64]()
do {
let database = try Library(url:database)
try Importer(media:media,waveformCache:cache).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
importedIDs = try Importer(media:media).run(urls,library:database,cancelled:{Task.isCancelled},progress:{ update in
latest = update
Task { @MainActor [weak self] in
guard let self,self.importToken == token else { return };self.importProgress = update
@@ -493,13 +500,14 @@ struct SongDragReference: Codable, Sendable {
} catch is CancellationError { latest.cancelled = true;latest.current = "Import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "Import stopped" }
latest.finished = true
let result = latest
let result = latest,ids = Set(importedIDs)
await MainActor.run { [weak self] in
guard let self,self.importToken == token else { return }
self.importToken = nil;self.importTask = nil;self.isImporting = false
self.perform { try self.refresh() }
self.importProgress = result
if let failure = result.failure { self.error = failure }
if !result.cancelled { self.queueImportedWaveforms(ids.union(self.songs.filter{!beforeIDs.contains($0.id)}.map(\.id))) }
}
}
}
@@ -652,6 +660,23 @@ struct StudioSearchField: View {
.overlay(RoundedRectangle(cornerRadius:7).strokeBorder(.primary.opacity(0.12)))
}
}
struct BackgroundWaveformStatus: View {
let progress: WaveformJobProgress
let cancel: () -> Void
let dismiss: () -> Void
var body: some View {
HStack(spacing:10) {
Image(systemName:progress.finished ? "checkmark.circle.fill" : "waveform").foregroundStyle(progress.cancelled || progress.issues > 0 ? Color.orange : progress.finished ? .green : .cyan)
VStack(alignment:.leading,spacing:2) {
Text(progress.finished ? progress.cancelled ? "Background waveforms cancelled" : "Background waveforms complete" : "Preparing waveforms in the background").font(.caption.weight(.semibold))
Text("\(progress.processed) / \(progress.total) · \(progress.generated) generated · \(progress.cached) reused · \(progress.issues) issues").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if progress.finished { Button(action:dismiss) { Image(systemName:"xmark") }.buttonStyle(.plain).help("Dismiss waveform results") }
else { ProgressView(value:Double(progress.processed),total:Double(max(1,progress.total))).tint(.cyan).frame(width:120);Button("Cancel",action:cancel).controlSize(.small) }
}.padding(10).background(Color.cyan.opacity(0.05),in:RoundedRectangle(cornerRadius:8))
}
}
struct WaveformProgressCard: View {
let progress: WaveformJobProgress
let running: Bool
@@ -961,6 +986,9 @@ struct LibraryView: View {
}.frame(width:920).background(Color(nsColor:.windowBackgroundColor))
}
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.automaticWaveformProgress {
BackgroundWaveformStatus(progress:progress,cancel:{model.automaticWaveforms.cancel()},dismiss:{model.automaticWaveformProgress = nil}).padding(.horizontal,18).padding(.vertical,8)
}
if let progress = model.waveformJob {
WaveformProgressCard(progress:progress,running:model.generatingWaveforms,cancel:{model.cancelWaveformGeneration()},details:{waveformDetails = true},dismiss:{model.dismissWaveformProgress()})
.padding(.horizontal,18).padding(.vertical,10)
+38
View File
@@ -188,3 +188,41 @@ extension WaveformBatch {
progress(state);return state
}
}
/// Serial batches of background analysis, independent of import completion.
@MainActor final class PostImportWaveformQueue {
private var pending = [Int64:Song]()
private var locations = [MediaLocation]()
private var cache: URL?
private var task: Task<Void,Never>?
private var token = UUID()
private var update: ((WaveformJobProgress) -> Void)?
private var completed: (([Song]) -> Void)?
private var latest: WaveformJobProgress?
var isRunning: Bool { task != nil }
func enqueue(_ songs: [Song],locations: [MediaLocation],cache: URL,update: @escaping (WaveformJobProgress) -> Void,completed: @escaping ([Song]) -> Void) {
for song in songs { pending[song.id] = song }
self.locations = locations;self.cache = cache;self.update = update;self.completed = completed
if task == nil { startNext() }
}
private func startNext() {
guard !pending.isEmpty,let cache else { return }
let songs = pending.values.sorted{$0.id < $1.id},locations = self.locations,id = UUID()
pending.removeAll();token = id;latest = .init(total:songs.count);update?(latest!)
task = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:songs,locations:locations,cache:cache) { value in
Task { @MainActor [weak self] in guard let self,self.token == id,self.task != nil else { return };self.latest = value;self.update?(value) }
}
await MainActor.run { [weak self] in
guard let self,self.token == id else { return }
self.task = nil;self.latest = result;self.update?(result);self.completed?(songs)
self.startNext()
}
}
}
func cancel() {
token = UUID();pending.removeAll();task?.cancel();task = nil
if var value = latest { value.finished = true;value.cancelled = true;value.current = "Background waveform job cancelled";latest = value;update?(value) }
}
deinit { task?.cancel() }
}
+49 -11
View File
@@ -23,16 +23,20 @@ import CoreAudio
@Published var loopEnabled = false { didSet {
if loopEnabled { prepareNativeLoop() }
else {
loopBufferTask?.cancel();loopBufferTask = nil;loopBuffer = nil
if nativeLoopActive { let position = displayTime;nativeLoopActive = false;schedule(at:position,play:playing) }
loopBufferTask?.cancel();loopBufferTask = nil
if nativeLoopActive,!exitNativeLoopWithoutRestart() { let position = displayTime;nativeLoopActive = false;schedule(at:position,play:playing) }
loopBuffer = nil;preparedRawLoop = nil
}
} }
private var loopBufferTask: Task<Void,Never>?
private var loopBuffer: AVAudioPCMBuffer?
private var preparedRawLoop: AVAudioPCMBuffer?
private var scheduledRawLoop: AVAudioPCMBuffer?
private var preparedLoop: ClosedRange<Double>?
private var scheduledLoop: ClosedRange<Double>?
private var pendingLoopSwitch: (at: Double,range: ClosedRange<Double>)?
private var pendingLoopSwitch: (at: Double,range: ClosedRange<Double>,raw: AVAudioPCMBuffer)?
private(set) var loopAudioRestartCount = 0
private(set) var audioScheduleRestartCount = 0
private var rawAudioPosition: Double {
guard let file,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) else { return time }
return anchor+Double(current.sampleTime)/file.processingFormat.sampleRate
@@ -47,19 +51,53 @@ 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 position = displayTime,rate = file.processingFormat.sampleRate
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) }
let now = mach_absolute_time(),elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime))*tempo : 0
anchor = position-Double(clock.sampleTime)/rate-elapsed;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.
node.scheduleBuffer(bridge,at:nil,options:.interrupts,completionHandler:nil)
let next = min(file.length,AVAudioFramePosition(position*rate)+AVAudioFramePosition(count))
if next < file.length {
node.scheduleSegment(file,startingFrame:next,frameCount:AVAudioFrameCount(min(file.length-next,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [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?() }
}
}
}
startTimer();self.render();return true
}
private func prepareNativeLoop() {
loopBufferTask?.cancel()
guard loopEnabled,let start = loopIn,let end = loopOut,end > start,let url = audioURL else { return }
// Bound memory for long manual loops; common beat loops stay sample-timed.
guard let file,(end-start)*file.processingFormat.sampleRate*Double(file.processingFormat.channelCount)*4 <= 64*1024*1024 else { return }
guard let file,(end-start)*file.processingFormat.sampleRate*Double(file.processingFormat.channelCount)*8 <= 64*1024*1024 else { return }
let songID = currentSongID
loopBufferTask = Task { @MainActor [weak self] in
let worker = Task.detached(priority:.userInitiated) { () throws -> AVAudioPCMBuffer in
let worker = Task.detached(priority:.userInitiated) { () throws -> (AVAudioPCMBuffer,AVAudioPCMBuffer) in
let source = try AVAudioFile(forReading:url)
let first = AVAudioFramePosition(start*source.processingFormat.sampleRate)
let count = AVAudioFrameCount(max(1,min(source.length-first,AVAudioFramePosition((end-start)*source.processingFormat.sampleRate))))
guard let buffer = AVAudioPCMBuffer(pcmFormat:source.processingFormat,frameCapacity:count) else { throw LibraryError.message("Cannot prepare loop audio") }
source.framePosition = first;try source.read(into:buffer,frameCount:count);try Task.checkCancellation()
guard let raw = AVAudioPCMBuffer(pcmFormat:buffer.format,frameCapacity:buffer.frameLength) else { throw LibraryError.message("Cannot prepare loop continuation") }
raw.frameLength = buffer.frameLength
let sourceBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList),rawBuffers = UnsafeMutableAudioBufferListPointer(raw.mutableAudioBufferList)
for i in sourceBuffers.indices { if let source = sourceBuffers[i].mData,let target = rawBuffers[i].mData { memcpy(target,source,Int(sourceBuffers[i].mDataByteSize)) } }
// Meet the first sample at the loop seam without changing its beat length.
if let data = buffer.floatChannelData {
let frames = Int(buffer.frameLength),stride = Int(buffer.stride)
@@ -74,13 +112,13 @@ import CoreAudio
}
}
}
return buffer
return (buffer,raw)
}
do {
let buffer = try await withTaskCancellationHandler(operation:{try await worker.value},onCancel:{worker.cancel()})
let (buffer,raw) = try await withTaskCancellationHandler(operation:{try await worker.value},onCancel:{worker.cancel()})
guard !Task.isCancelled,let self,self.currentSongID == songID,self.loopEnabled,self.loopIn == start,self.loopOut == end else { return }
let position = self.displayTime,resume = self.playing
self.loopBuffer = buffer;self.preparedLoop = start...end
self.loopBuffer = buffer;self.preparedRawLoop = raw;self.preparedLoop = start...end
if self.nativeLoopActive,resume {
let raw = self.rawAudioPosition
let activeRange: ClosedRange<Double>
@@ -88,7 +126,7 @@ import CoreAudio
else { activeRange = self.scheduledLoop ?? start...end }
let remaining = max(0,activeRange.upperBound-self.loopPosition(raw))
self.node.scheduleBuffer(buffer,at:nil,options:[.loops,.interruptsAtLoop],completionHandler:nil)
self.pendingLoopSwitch = (raw+remaining,start...end)
self.pendingLoopSwitch = (raw+remaining,start...end,self.preparedRawLoop!)
} else {
self.loopAudioRestartCount += 1
self.schedule(at:position < end ? position : start,play:resume,notifyPlayback:false)
@@ -382,7 +420,7 @@ import CoreAudio
private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true) {
guard let file else { return }
cancelPendingPlayback();generation = UUID(); let token = generation
node.stop();if !fading { setFade(1) }
node.stop();audioScheduleRestartCount += 1;if !fading { setFade(1) }
let rate = file.processingFormat.sampleRate
let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate)))
anchor = Double(start)/rate; time = anchor
@@ -393,7 +431,7 @@ import CoreAudio
let remainder = AVAudioFrameCount(max(1,endFrame-start))
node.scheduleSegment(file,startingFrame:start,frameCount:remainder,at:nil)
node.scheduleBuffer(loopBuffer,at:nil,options:.loops)
nativeLoopActive = true;scheduledLoop = range
nativeLoopActive = true;scheduledLoop = range;scheduledRawLoop = preparedRawLoop
if play { if notifyPlayback { startNodePlayback() } else { node.play() } }
playing = play;render();return
}