From 50e7fb358a7aa03c69669b5b5c5c024470496870 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 6 Nov 2026 13:51:45 +0000 Subject: [PATCH] removed unwanted bar now with waveforms, major updates --- KJamsClone/DJWorkstation.swift | 18 +++++++- KJamsClone/KJMixProApp.swift | 36 ++++++++++++++-- KJamsClone/LibraryScanner.swift | 38 +++++++++++++++++ KJamsClone/Player.swift | 60 ++++++++++++++++++++++----- KJamsCloneTests/KJamsCloneTests.swift | 58 ++++++++++++++++++++------ docs/DJ_WORKSTATION.md | 4 +- 6 files changed, 184 insertions(+), 30 deletions(-) diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 4124ba8..4ef06f8 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -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) { + 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? @@ -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() diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index 8f3996c..0594fbb 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -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) { + 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? @@ -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) diff --git a/KJamsClone/LibraryScanner.swift b/KJamsClone/LibraryScanner.swift index 19f143f..79fe9fd 100644 --- a/KJamsClone/LibraryScanner.swift +++ b/KJamsClone/LibraryScanner.swift @@ -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? + 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() } +} diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 874147c..112a97c 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -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? private var loopBuffer: AVAudioPCMBuffer? + private var preparedRawLoop: AVAudioPCMBuffer? + private var scheduledRawLoop: AVAudioPCMBuffer? private var preparedLoop: ClosedRange? private var scheduledLoop: ClosedRange? - private var pendingLoopSwitch: (at: Double,range: ClosedRange)? + private var pendingLoopSwitch: (at: Double,range: ClosedRange,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...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 @@ -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 } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 6188b97..8a7ad98 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -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)) } + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index c7d6256..967a4f8 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -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.