From cd3fa6332f10704194acd48a0e2f1a8c777d505f Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 6 Nov 2026 09:53:53 +0000 Subject: [PATCH] removed unwanted bar now with waveforms --- KJamsClone/DJWorkstation.swift | 47 +++++++++++++++++++++++++-- KJamsClone/KJMixProApp.swift | 5 +-- KJamsCloneTests/KJamsCloneTests.swift | 16 +++++++++ docs/DJ_WORKSTATION.md | 2 ++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 0f306af..ae5b81f 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -151,6 +151,28 @@ struct DJAudioDevice: Identifiable, Equatable { } @MainActor final class DJSystem: ObservableObject { + @Published private(set) var waveformJob: WaveformJobProgress? + @Published private(set) var generatingWaveforms = false + private var waveformTask: Task? + func generateWaveforms(_ ids: Set? = nil,force: Bool = false) { + guard !generatingWaveforms,!importing else { error = "Wait for the current import or waveform job to finish first.";return } + let tracks = songs.filter { ids == nil || ids!.contains($0.id) } + guard !tracks.isEmpty else { error = "Choose a DJ library track first.";return } + let cache = root.appendingPathComponent("Waveforms") + generatingWaveforms = true;waveformJob = .init(total:tracks.count) + waveformTask = Task.detached(priority:.utility) { [weak self] in + let result = WaveformBatch.run(songs:tracks,locations:[],cache:cache,force:force,cancelled:{Task.isCancelled}) { update in + Task { @MainActor [weak self] in self?.waveformJob = update } + } + await MainActor.run { [weak self] in + guard let self else { return } + self.waveformJob = result;self.generatingWaveforms = false;self.waveformTask = nil + for deck in [self.a,self.b] where tracks.contains(where:{$0.id == deck.song?.id}) { deck.player.refreshCachedWaveform() } + } + } + } + func cancelWaveforms() { waveformTask?.cancel();waveformJob?.current = "Cancelling…" } + func dismissWaveforms() { if !generatingWaveforms { waveformJob = nil } } @Published private(set) var favourites = Set() let a: DJChannel let b: DJChannel @@ -230,7 +252,7 @@ struct DJAudioDevice: Identifiable, Equatable { func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } } func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) } func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) { - guard !importing,!urls.isEmpty else { return } + guard !importing,!generatingWaveforms,!urls.isEmpty else { return } importing = true;importProgress = .init();importLogVisible = true let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms"),replacedSong = loadInto.flatMap{channel($0).song?.id} importTask = Task.detached(priority:.utility) { [weak self] in @@ -264,7 +286,7 @@ struct DJAudioDevice: Identifiable, Equatable { requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song) } func cancelReplacement() { pendingReplacement = nil;continueFileLoads() } - deinit { importTask?.cancel() } + deinit { importTask?.cancel();waveformTask?.cancel() } func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 } @@ -724,7 +746,7 @@ private struct DJLibraryPageScroll: NSViewRepresentable { private var observation: NSObjectProtocol? private var endObservation: NSObjectProtocol? private var advanced = false - private var lastY = 0.0 + private var lastY: CGFloat = 0 override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } } func attach() { guard window != nil,scroll == nil else { return } @@ -772,6 +794,7 @@ private struct DJQueueButton: View { struct DJConsole: View { @ObservedObject var system: DJSystem + @State private var waveformDetails = false @State private var favouritesOnly = false @State private var queueVisible = false @State private var query = "" @@ -795,6 +818,9 @@ struct DJConsole: View { header DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) browser.frame(maxWidth:.infinity,maxHeight:.infinity) + if let progress = system.waveformJob { + WaveformProgressCard(progress:progress,running:system.generatingWaveforms,studio:"DJ",cancel:{system.cancelWaveforms()},details:{waveformDetails = true},dismiss:{system.dismissWaveforms()}) + } if let progress = system.importProgress { HStack { Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() @@ -818,6 +844,13 @@ struct DJConsole: View { } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } .sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) } + .sheet(isPresented:$waveformDetails) { + VStack(alignment:.leading,spacing:16) { + Text("DJ Waveform Details").font(.title2.bold()) + ScrollView { Text(system.waveformJob?.messages.joined(separator:"\n\n") ?? "").textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) } + HStack { Spacer();Button("Done") { waveformDetails = false }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:620,height:400) + } .sheet(isPresented:$queueVisible) { VStack(spacing:16) { DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity) @@ -895,6 +928,14 @@ struct DJConsole: View { Divider() Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) } Button("Add to DJ Queue") { system.automix.enqueue([song]) } + Menu("Waveforms") { + Button("Generate Waveform for This Track") { system.generateWaveforms([song.id]) } + Button("Generate Waveforms for Loaded Decks") { system.generateWaveforms(Set([system.a.song?.id,system.b.song?.id].compactMap{$0})) }.disabled(system.a.song == nil && system.b.song == nil) + Button("Generate Waveforms for All DJ Library Tracks") { system.generateWaveforms() } + Divider() + Button("Regenerate This Waveform") { system.generateWaveforms([song.id],force:true) } + Button("Regenerate All DJ Library Waveforms") { system.generateWaveforms(force:true) } + }.disabled(system.generatingWaveforms || system.importing) Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } } if let playlist { Button("Move Earlier") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:-1) };refreshPlaylist() } diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index e0d0ea6..1517cef 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -616,9 +616,10 @@ private struct ImportProgressDialog: View { } } } -private struct WaveformProgressCard: View { +struct WaveformProgressCard: View { let progress: WaveformJobProgress let running: Bool + var studio = "Karaoke" let cancel: () -> Void let details: () -> Void let dismiss: () -> Void @@ -632,7 +633,7 @@ private struct WaveformProgressCard: View { Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit() }.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent") VStack(alignment:.leading,spacing:7) { - HStack { Text(running ? "Preparing Karaoke Waveforms" : "Waveform Results").font(.callout.weight(.semibold));Spacer();Text("\(progress.processed.formatted()) / \(progress.total.formatted())").font(.caption).monospacedDigit().foregroundStyle(.secondary) } + HStack { Text(running ? "Preparing "+studio+" Waveforms" : "Waveform Results").font(.callout.weight(.semibold));Spacer();Text("\(progress.processed.formatted()) / \(progress.total.formatted())").font(.caption).monospacedDigit().foregroundStyle(.secondary) } GeometryReader { proxy in ZStack(alignment:.leading) { Capsule().fill(colour.opacity(0.12)) diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 5f19361..8465610 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2684,4 +2684,20 @@ extension KJamsCloneTests { XCTAssertNil(system.error) } + @MainActor func testDJWaveformBatchSavesAndReusesCache() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let system = DJSystem(storageURL:root) + let id = try XCTUnwrap(system.library?.add(title:"Demo",audio:fixture.path,cdg:nil)) + try system.refresh() + system.generateWaveforms([id]) + for _ in 0..<300 where system.generatingWaveforms { try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertEqual(system.waveformJob?.generated,1);XCTAssertEqual(system.waveformJob?.issues,0) + XCTAssertNotNil(try WaveformCache.load(audio:fixture,directory:root.appendingPathComponent("Waveforms"))) + system.generateWaveforms([id]) + for _ in 0..<300 where system.generatingWaveforms { try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertEqual(system.waveformJob?.cached,1);XCTAssertEqual(system.waveformJob?.generated,0) + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 57aacad..4b382f8 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -87,3 +87,5 @@ Each deck has a vertical channel-level fader beside its EQ/filter controls. The DJ library rows include the same gold favourite star as Karaoke Studio. Favourites are saved independently in the DJ library, with a Favourites filter and a right-click toggle. Dragging a DJ waveform previews short audio grains through that deck’s EQ, level and output routing while paused; releasing stops the preview and retains the selected position. Scrubbing a playing deck continues playback. This is audio preview seeking, rather than reverse vinyl scratching. + +The DJ library advances to the next page when a user scroll reaches the bottom. Page changes return the list to the top and select the first track. Right-click a DJ track and choose Waveforms to generate its cache, prepare loaded decks or the entire DJ library, or force regeneration. Batch analysis runs on a utility worker with saved-cache reuse, progress and cancellation.