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 { @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>() @Published private(set) var favourites = Set<Int64>()
let a: DJChannel let a: DJChannel
let b: 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 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 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) { 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 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} 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 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) requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
} }
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() } 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 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 } 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 observation: NSObjectProtocol?
private var endObservation: NSObjectProtocol? private var endObservation: NSObjectProtocol?
private var advanced = false 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() } } override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } }
func attach() { func attach() {
guard window != nil,scroll == nil else { return } guard window != nil,scroll == nil else { return }
@@ -772,6 +794,7 @@ private struct DJQueueButton: View {
struct DJConsole: View { struct DJConsole: View {
@ObservedObject var system: DJSystem @ObservedObject var system: DJSystem
@State private var waveformDetails = false
@State private var favouritesOnly = false @State private var favouritesOnly = false
@State private var queueVisible = false @State private var queueVisible = false
@State private var query = "" @State private var query = ""
@@ -795,6 +818,9 @@ struct DJConsole: View {
header header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
browser.frame(maxWidth:.infinity,maxHeight:.infinity) 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 { if let progress = system.importProgress {
HStack { HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() 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:$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: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) { .sheet(isPresented:$queueVisible) {
VStack(spacing:16) { VStack(spacing:16) {
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity) DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity)
@@ -895,6 +928,14 @@ struct DJConsole: View {
Divider() Divider()
Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) } 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]) } 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() } } } Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist { if let playlist {
Button("Move Earlier") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:-1) };refreshPlaylist() } 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 progress: WaveformJobProgress
let running: Bool let running: Bool
var studio = "Karaoke"
let cancel: () -> Void let cancel: () -> Void
let details: () -> Void let details: () -> Void
let dismiss: () -> Void let dismiss: () -> Void
@@ -632,7 +633,7 @@ private struct WaveformProgressCard: View {
Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit() Text("\(Int(fraction*100))%").font(.system(size:11,weight:.semibold)).monospacedDigit()
}.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent") }.frame(width:46,height:46).accessibilityLabel("Waveform progress").accessibilityValue("\(Int(fraction*100)) percent")
VStack(alignment:.leading,spacing:7) { 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 GeometryReader { proxy in
ZStack(alignment:.leading) { ZStack(alignment:.leading) {
Capsule().fill(colour.opacity(0.12)) Capsule().fill(colour.opacity(0.12))
+16
View File
@@ -2684,4 +2684,20 @@ extension KJamsCloneTests {
XCTAssertNil(system.error) 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. 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. 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.