removed unwanted bar now with waveforms

This commit is contained in:
Mark Gregory
2026-11-06 09:53:53 +00:00
parent 794fa595d7
commit cd3fa6332f
4 changed files with 65 additions and 5 deletions
+44 -3
View File
@@ -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<Void,Never>?
func generateWaveforms(_ ids: Set<Int64>? = 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<Int64>()
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() }
+3 -2
View File
@@ -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))
+16
View File
@@ -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)
}
}
+2
View File
@@ -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.