removed unwanted bar now with waveforms, major updates
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -1496,29 +1496,34 @@ final class KJamsCloneTests: XCTestCase {
|
||||
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
let song = try XCTUnwrap(model.library).add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();model.activeSection = "Rotation";model.selectedSinger = a
|
||||
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1060,height:760).preferredColorScheme(.dark));host.frame = NSRect(x:0,y:0,width:1060,height:760)
|
||||
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host
|
||||
final class RotationTestWindow: NSWindow { override var canBecomeKey: Bool { true } }
|
||||
let window = RotationTestWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host
|
||||
let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
|
||||
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
|
||||
try await Task.sleep(nanoseconds:250_000_000);host.layoutSubtreeIfNeeded()
|
||||
func capture(_ name: String) throws {
|
||||
host.layoutSubtreeIfNeeded();if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-rotation-test-"+name+".png")) }
|
||||
host.layoutSubtreeIfNeeded()
|
||||
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) {
|
||||
host.cacheDisplay(in:host.bounds,to:rep)
|
||||
if let data = rep.representation(using:.png,properties:[:]) {
|
||||
let attachment = XCTAttachment(data:data,uniformTypeIdentifier:"public.png");attachment.name = "Rotation "+name;attachment.lifetime = .deleteOnSuccess
|
||||
XCTContext.runActivity(named:"Rotation "+name) { $0.add(attachment) }
|
||||
}
|
||||
}
|
||||
}
|
||||
func click(_ x: Double,_ top: Double) async throws {
|
||||
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
|
||||
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first)
|
||||
let rect = tabs.contentRect
|
||||
let point = tabs.convert(NSPoint(x:rect.minX+x,y:tabs.isFlipped ? rect.minY+top : rect.maxY-top),to:nil)
|
||||
let point = host.convert(NSPoint(x:x,y:host.isFlipped ? top : host.bounds.height-top),to:nil)
|
||||
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
|
||||
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
|
||||
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));window.sendEvent(event)
|
||||
}
|
||||
try await Task.sleep(nanoseconds:120_000_000)
|
||||
}
|
||||
try await click(485,502);try capture("songs")
|
||||
try await click(990,565);try capture("added")
|
||||
try await click(492,521);try capture("songs")
|
||||
try await click(960,595);try capture("added")
|
||||
XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song")
|
||||
try await click(518,507);try capture("reordered")
|
||||
try await click(520,526);try capture("reordered")
|
||||
XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam")
|
||||
try await click(335,387)
|
||||
try await click(340,405)
|
||||
XCTAssertTrue(model.player.playing,"Actual Start Rotation button must play");XCTAssertTrue(model.rotationPlaybackActive);XCTAssertEqual(model.playbackSinger,a);XCTAssertNil(model.error)
|
||||
}
|
||||
|
||||
@@ -2627,6 +2632,7 @@ extension KJamsCloneTests {
|
||||
XCTAssertTrue(report.finished);XCTAssertNil(report.failure)
|
||||
XCTAssertEqual(report.completed,48);XCTAssertEqual(report.skipped,47)
|
||||
XCTAssertEqual(report.reportLines.count,48);XCTAssertEqual(system.songs.count,1)
|
||||
for _ in 0..<300 where system.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:20_000_000) }
|
||||
let song = try XCTUnwrap(system.songs.first)
|
||||
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:root.appendingPathComponent("DJ/Waveforms")))
|
||||
}
|
||||
@@ -2859,8 +2865,9 @@ extension KJamsCloneTests {
|
||||
try await Task.sleep(nanoseconds:300_000_000)
|
||||
XCTAssertEqual(player.loopAudioRestartCount,restarts,"Resizing an active loop must not restart the audio node")
|
||||
XCTAssertTrue(player.nativeLoopActive);XCTAssertLessThan(player.displayTime,0.301)
|
||||
let exit = player.displayTime
|
||||
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")
|
||||
XCTAssertFalse(player.nativeLoopActive);XCTAssertTrue(player.playing)
|
||||
XCTAssertEqual(player.time,exit,accuracy:0.03)
|
||||
try await Task.sleep(nanoseconds:180_000_000)
|
||||
@@ -3019,4 +3026,31 @@ extension KJamsCloneTests {
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath:cache.path))
|
||||
}
|
||||
|
||||
@MainActor func testImportsFinishBeforeQueuedWaveformsAndAllowAnotherImport() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at:root) }
|
||||
let id = UUID(),policy = WaveformWorkload.shared
|
||||
policy.setPlaying(true,id:id);try policy.acquire(cancelled:{false})
|
||||
var held = true
|
||||
defer { if held { policy.release() };policy.setPlaying(false,id:id) }
|
||||
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
|
||||
let fixture = fixtures.appendingPathComponent("Demo.mp3"),second = fixtures.appendingPathComponent("TestTrack.mp3")
|
||||
let model = AppModel(storageURL:root.appendingPathComponent("Karaoke")),dj = DJSystem(storageURL:root.appendingPathComponent("DJ"))
|
||||
model.importFiles([fixture]);dj.importFiles([fixture])
|
||||
for _ in 0..<300 where model.isImporting || dj.importing { try await Task.sleep(nanoseconds:10_000_000) }
|
||||
XCTAssertFalse(model.isImporting);XCTAssertFalse(dj.importing)
|
||||
XCTAssertEqual(model.songs.count,1);XCTAssertEqual(dj.songs.count,1)
|
||||
XCTAssertTrue(model.automaticWaveforms.isRunning);XCTAssertTrue(dj.automaticWaveforms.isRunning)
|
||||
let karaokeSong = try XCTUnwrap(model.songs.first)
|
||||
XCTAssertThrowsError(try WaveformCache.load(audio:URL(fileURLWithPath:karaokeSong.audio),directory:model.root.appendingPathComponent("Waveforms")))
|
||||
dj.importFiles([second])
|
||||
for _ in 0..<300 where dj.importing { try await Task.sleep(nanoseconds:10_000_000) }
|
||||
XCTAssertFalse(dj.importing);XCTAssertEqual(dj.songs.count,2,"A background waveform job must not block subsequent imports")
|
||||
policy.release();held = false
|
||||
for _ in 0..<500 where model.automaticWaveforms.isRunning || dj.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:10_000_000) }
|
||||
XCTAssertFalse(model.automaticWaveforms.isRunning);XCTAssertFalse(dj.automaticWaveforms.isRunning)
|
||||
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:karaokeSong.audio),directory:model.root.appendingPathComponent("Waveforms")))
|
||||
for song in dj.songs { XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:dj.waveformDirectory)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ DJ imports show a live progress panel with a coloured progress bar, imported/ski
|
||||
|
||||
Karaoke Studio imports run in the background without the DJ import modal; import failures use the existing error alert.
|
||||
|
||||
New tracks imported into either studio also receive saved waveforms on the import worker. DJ decks reuse the DJ waveform cache. Waveform failures leave the imported track available and add a warning to its import log. Karaoke waveform batch jobs show a coloured progress bar and percentage ring, with generated/reused/skipped/issue counts and cancellation.
|
||||
Both studios finish importing and publish their library tracks first, then submit saved waveform preparation to a separate utility-priority queue. Further imports can continue while this queue is busy. A non-modal background status shows generated/reused/issue counts and Cancel. Manual Generate/Regenerate retains the existing progress dialog. DJ decks reuse the DJ waveform cache; waveform failures never undo an imported track. Karaoke waveform batch jobs show a coloured progress bar and percentage ring, with generated/reused/skipped/issue counts and cancellation.
|
||||
|
||||
Each deck has a vertical channel-level fader beside its EQ/filter controls. The DJ library search uses the same compact rounded field as Karaoke Studio.
|
||||
|
||||
@@ -127,3 +127,5 @@ New DJ imports default to referencing their original files, avoiding a second me
|
||||
Right-click selected DJ tracks to remove their library entries. DJ Settings → Library also has Clear DJ Library and Clean Unused Files. Removal requires confirmation and keeps media files. Cleanup scans on a worker, previews count/size/paths, and requires confirmation before moving unreferenced hash-named managed media and stale waveform caches to Trash. Referenced originals and unrecognised personal files are excluded. The cleanup preview is rechecked before use; files newly referenced or changed are skipped. Waveform cleanup is deferred when source-file availability prevents cache validation. Clearing the DJ library also clears its queue/favourites while leaving original files and empty playlist definitions.
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user