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
}
+46 -12
View File
@@ -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)) }
}
}
+3 -1
View File
@@ -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.