diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index d52cbe9..921e323 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -115,7 +115,7 @@ class DJWaveMouseView: NSView { if relativeSeek { let target = min(player.duration,max(0,initialTime-(x-initialX)*drag.secondsPerPixel)) if abs(player.time-target) > 0.0001 { player.scrub(to:target) } - freeze?(drag.viewport+target-initialTime) + freeze?(drag.viewport+player.displayTime-initialTime) NSCursor.closedHand.set() } else { player.scrub(to:drag.viewport+x*drag.secondsPerPixel) } } diff --git a/KJamsClone/DJNativeDragDrop.swift b/KJamsClone/DJNativeDragDrop.swift index a007cfe..76514f5 100644 --- a/KJamsClone/DJNativeDragDrop.swift +++ b/KJamsClone/DJNativeDragDrop.swift @@ -22,8 +22,11 @@ struct DJLibraryDragTitle: NSViewRepresentable { var selectAll: (() -> Void)? = nil func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() } func updateNSView(_ view: DJLibraryDragView,context: Context) { + let redraw = view.title != title || view.fontSize != fontSize view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select;view.doubleClick = doubleClick;view.selectAllTracks = selectAll - view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true + if redraw { + view.toolTip = "Double-click to load alternating decks, or drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true + } } func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) } } diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index 563ef6b..e916db4 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -647,8 +647,12 @@ struct StudioSettingsDialog: View { var body: some View { VStack(spacing:0) { HStack { - Text(category).font(.headline).frame(maxWidth:.infinity,alignment:.leading) - Picker("Settings for",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.pickerStyle(.segmented).frame(width:280) + VStack(alignment:.leading,spacing:4) { Text("Settings").font(.title2.bold());Text(category).font(.callout).foregroundStyle(.secondary) } + Spacer(minLength:24) + VStack(alignment:.leading,spacing:6) { + Text("Workspace").font(.caption).foregroundStyle(.secondary).fixedSize() + Picker("Workspace",selection:$scope) { Text("Karaoke Studio").tag("Karaoke");Text("DJ Studio").tag("DJ") }.labelsHidden().pickerStyle(.segmented).frame(width:340) + }.fixedSize(horizontal:true,vertical:true) }.padding(.horizontal,24).padding(.top,18).padding(.bottom,10) HStack(spacing:8) { ForEach(categories,id:\.self) { name in diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 374eee3..7bb4995 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -19,6 +19,26 @@ enum DJMix { } } +struct DJGraphicEQSettings: Codable, Equatable { + var enabled = false + var preset = "Flat" + var gains = [Double](repeating:0,count:10) + static let presets: [(String,[Double])] = [ + ("Flat",[0,0,0,0,0,0,0,0,0,0]), + ("Rock",[4,3,2,0,-2,-1,1,3,4,3]), + ("Pop",[-1,1,3,4,2,0,-1,-1,1,2]), + ("Jazz",[3,2,1,1,-1,-1,0,2,3,3]), + ("Vocal",[-4,-3,-2,0,2,4,4,2,0,-2]), + ("Bass Boost",[6,5,4,2,0,0,0,0,0,0]), + ("Treble Boost",[0,0,0,0,0,1,2,3,4,5]) + ] + var sanitized: Self { + var value = self + value.gains = (0..<10).map { gains.indices.contains($0) && gains[$0].isFinite ? min(12,max(-12,gains[$0])) : 0 } + return value + } +} + struct DJPreferences: Codable, Equatable { var autoPlayOnLoad = false var resetEQOnLoad = false @@ -67,16 +87,17 @@ struct DJPreferences: Codable, Equatable { player.defineLoop(start:start,end:min(player.duration,start+length)) } } - var master: Double = 0.8 { didSet { updateLevel() } } - var crossWeight: Double = 1 { didSet { updateLevel() } } + var master: Double = 0.8 { didSet { if master != oldValue { updateLevel() } } } + var crossWeight: Double = 1 { didSet { if crossWeight != oldValue { updateLevel() } } } private var taps = [Double]() init(_ letter: String,waveformCache: URL? = nil) { self.letter = letter;player = Player(waveformCacheDirectory:waveformCache,persistsMix:false);player.audibleScrubbing = true;player.autoFade = false;updateLevel() } - func updateLevel() { player.volume = min(1,max(0,level * master * crossWeight)) } + func updateLevel() { let value = min(1,max(0,level * master * crossWeight));if player.volume != value { player.volume = value } } func load(_ song: Song) throws { var audioOnly = song;audioOnly.cdg = nil do { try player.load(audioOnly,startPlaying:false) } catch { self.song = nil;throw error } self.song = song;bpmConfirmed = false;bpmEstimated = false;bpm = 120;taps = [];loadTrackInfo(song) } + func updateTrackMetadata(_ song: Song) { self.song = song;player.title = song.title } func unload() { infoTask?.cancel();analysisTask?.cancel();player.stop();song = nil;bpmConfirmed = false;artwork = nil;analysingBPM = false } private func loadTrackInfo(_ song: Song) { infoTask?.cancel();analysisTask?.cancel();artwork = nil;analysingBPM = true @@ -151,6 +172,23 @@ struct DJAudioDevice: Identifiable, Equatable { } @MainActor final class DJSystem: ObservableObject { + @Published var namesBusy = false + private var graphicSaveTask: Task? + @Published var graphicEQ = DJGraphicEQSettings() { didSet { + applyGraphicEQ() + graphicSaveTask?.cancel() + let url = root.appendingPathComponent("DJGraphicEQ.json"),value = graphicEQ.sanitized + graphicSaveTask = Task { @MainActor in + do { try await Task.sleep(nanoseconds:200_000_000);try JSONEncoder().encode(value).write(to:url,options:.atomic) } catch { } + } + } } + private func applyGraphicEQ() { + for player in [a.player,b.player,preview] { player.applyGraphicEQ(graphicEQ.sanitized.gains,enabled:graphicEQ.enabled) } + } + func chooseGraphicPreset(_ name: String) { + guard let gains = DJGraphicEQSettings.presets.first(where:{$0.0 == name})?.1 else { return } + graphicEQ = .init(enabled:true,preset:name,gains:gains) + } @Published private(set) var waveformJob: WaveformJobProgress? @Published private(set) var generatingWaveforms = false private var waveformTask: Task? @@ -161,7 +199,7 @@ struct DJAudioDevice: Identifiable, Equatable { 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 + let result = await WaveformBatch.runConcurrent(songs:tracks,locations:[],cache:cache,force:force) { update in Task { @MainActor [weak self] in self?.waveformJob = update } } await MainActor.run { [weak self] in @@ -229,6 +267,8 @@ struct DJAudioDevice: Identifiable, Equatable { perform { try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh() + if let data = try? Data(contentsOf:root.appendingPathComponent("DJGraphicEQ.json")),let saved = try? JSONDecoder().decode(DJGraphicEQSettings.self,from:data) { graphicEQ = saved.sanitized } + applyGraphicEQ() favourites = Set((try library?.setting("DJ.Favourites.v1") ?? "").split(separator:",").compactMap{Int64($0)}) if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized } if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) } @@ -746,14 +786,23 @@ private enum DJStyle { /// Observe user scrolling, rather than row appearance (List preloads offscreen rows). private struct DJLibraryPageScroll: NSViewRepresentable { + var page: Int let next: () -> Void func makeNSView(context: Context) -> Observer { Observer() } - func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach() } + func updateNSView(_ view: Observer,context: Context) { view.next = next;view.attach();view.setPage(page) } final class Observer: NSView { var next: (() -> Void)? private weak var scroll: NSScrollView? private var observation: NSObjectProtocol? private var endObservation: NSObjectProtocol? + private var currentPage = 0 + func setPage(_ page: Int) { + guard page != currentPage else { return };currentPage = page + DispatchQueue.main.async { [weak self] in + guard let scroll = self?.scroll else { return } + scroll.contentView.scroll(to:.zero);scroll.reflectScrolledClipView(scroll.contentView);self?.lastY = 0 + } + } private var advanced = false private var lastY: CGFloat = 0 override func viewDidMoveToWindow() { super.viewDidMoveToWindow();DispatchQueue.main.async { [weak self] in self?.attach() } } @@ -801,8 +850,27 @@ private struct DJQueueButton: View { } } +private struct DJLibrarySnapshot: View, Equatable { + let revision: UUID + let selection: Set + let selected: Int64? + let page: Int + let busy: Bool + let font: Double + let height: Double + @ViewBuilder let content: () -> Content + static func == (lhs: Self,rhs: Self) -> Bool { + lhs.revision == rhs.revision && lhs.selection == rhs.selection && lhs.selected == rhs.selected && lhs.page == rhs.page && lhs.busy == rhs.busy && lhs.font == rhs.font && lhs.height == rhs.height + } + var body: some View { content() } +} + struct DJConsole: View { @ObservedObject var system: DJSystem + @State private var reinterpreting = false + @State private var reinterpretIDs = Set() + @State private var editingTrack: Song? + @State private var graphicEQVisible = false @State private var waveformDetails = false @State private var favouritesOnly = false @State private var queueVisible = false @@ -815,10 +883,14 @@ struct DJConsole: View { @State private var playlistName = "" @State private var renaming = false @State private var page = 0 - private var songs: [Song] { + @State private var libraryRevision = UUID() + @State private var filteredTracks = [Song]() + private var songs: [Song] { filteredTracks } + private func updateFilteredTracks() { + libraryRevision = UUID() let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && (!favouritesOnly || system.favourites.contains($0.id)) && SongSearch.matches($0,query:query)} - if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} } - return all + if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});filteredTracks = playlistIDs.compactMap{lookup[$0]} } + else { filteredTracks = all } } private var selectedTracks: [Song] { songs.filter { selection.contains($0.id) } } private func selectTrack(_ id: Int64) { @@ -840,7 +912,7 @@ struct DJConsole: View { VStack(spacing:10) { header DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) - browser.frame(maxWidth:.infinity,maxHeight:.infinity) + 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.importProgress { HStack { Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() @@ -862,6 +934,9 @@ struct DJConsole: View { .interactiveDismissDisabled(system.importing) } } + .sheet(isPresented:$reinterpreting) { ReinterpretNamesSheet(model:system,ids:reinterpretIDs) } + .sheet(item:$editingTrack) { DJTrackMetadataDialog(system:system,song:$0) } + .sheet(isPresented:$graphicEQVisible) { DJGraphicEQDialog(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.waveformJob != nil},set:{if !$0 { system.dismissWaveforms() }})) { @@ -893,12 +968,13 @@ struct DJConsole: View { .alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } .onAppear { refreshPlaylist();connectMIDI() } .onChange(of:selected) { _ in connectMIDI() } - .onChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id };connectMIDI() } + .onChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id } } .onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] } .onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() } .onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() } - .onChange(of:query) { _ in page = 0 } - .onChange(of:favouritesOnly) { _ in page = 0 } + .onChange(of:query) { _ in updateFilteredTracks();page = 0 } + .onChange(of:favouritesOnly) { _ in updateFilteredTracks();page = 0 } + .onChange(of:system.favourites) { _ in updateFilteredTracks() } } private var header: some View { HStack { @@ -906,6 +982,7 @@ struct DJConsole: View { Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded)) Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary) Spacer() + Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.tint(system.graphicEQ.enabled ? .cyan : .accentColor) DJQueueButton(automix:system.automix,open:{queueVisible = true}) if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) } }.controlSize(.small) @@ -923,7 +1000,7 @@ struct DJConsole: View { } label: { Image(systemName:"folder.badge.plus") }.frame(width:25) Button { favouritesOnly.toggle() } label: { Label("Favourites",systemImage:favouritesOnly ? "star.fill" : "star") }.foregroundStyle(favouritesOnly ? Color.yellow : Color.secondary).help("Show favourite tracks only") Spacer(minLength:12) - TextField("Search DJ music",text:$query).textFieldStyle(.roundedBorder).controlSize(.regular).frame(maxWidth:260) + StudioSearchField(placeholder:"Search music",text:$query).frame(maxWidth:260) Button { system.automix.enqueue(selectedTracks) } label: { Image(systemName:"text.badge.plus") }.help("Add selected tracks to the DJ queue").disabled(selectedTracks.isEmpty) Text("\(selection.count) selected").font(.caption).foregroundStyle(.secondary) Button("Import Files / Folder…") { importMedia() } @@ -933,23 +1010,34 @@ struct DJConsole: View { Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1) }.controlSize(.small) ScrollViewReader { reader in - List(selection:$selection) { - ForEach(visible) { song in - HStack(spacing:8) { - Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)") - DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}).frame(maxWidth:.infinity,alignment:.leading) - Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading) - Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35) - Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue") + Table(visible,selection:$selection) { + TableColumn("Favourite") { song in + Button { system.toggleFavourite(song.id) } label: { GoldStar(filled:system.favourites.contains(song.id),size:15) }.buttonStyle(.plain).accessibilityLabel(system.favourites.contains(song.id) ? "Remove \(song.title) from favourites" : "Favourite \(song.title)") + }.width(55) + TableColumn("Title") { song in + DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selectTrack(song.id)},doubleClick:{system.loadFromLibraryDoubleClick(song)},selectAll:{selectAllTracks()}) + .frame(maxWidth:.infinity,alignment:.leading).frame(minHeight:system.settings.libraryRowHeight) + }.width(min:180,ideal:400,max:1600) + TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:100,ideal:200,max:800) + TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:90,ideal:180,max:800) + TableColumn("Time") { song in Text(DJStyle.stamp(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(min:45,ideal:60,max:100) + TableColumn("Rating") { song in + GoldRating(rating:Binding(get:{song.rating},set:{value in system.perform { try system.library?.rate(song.id,value);try system.refresh() } })) + }.width(min:95,ideal:105,max:140) + TableColumn("Load") { song in + HStack(spacing:7) { Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A") Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B") - }.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).id(song.id).buttonStyle(.bordered).controlSize(.mini) - .contextMenu { playlistMenu(song) } - } - if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) } - }.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6)) + }.buttonStyle(.bordered).controlSize(.small) + }.width(90) + }.font(.system(size:system.settings.libraryFont)) + .contextMenu(forSelectionType:Int64.self) { ids in + if let song = songs.first(where:{ids.contains($0.id)}) { playlistMenu(song) } + else { Button("Select All Songs") { selectAllTracks() } } + } + .scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:8)) .onCommand(#selector(NSResponder.selectAll(_:))) { selectAllTracks() } - .background(DJLibraryPageScroll { if page < pages-1 { page += 1 } }) + .background(DJLibraryPageScroll(page:page) { if page < pages-1 { page += 1 } }) .onChange(of:page) { _ in selected = visible.first?.id;selection = Set(visible.first.map{[$0.id]} ?? []) if let first = visible.first?.id { DispatchQueue.main.async { reader.scrollTo(first,anchor:.top) } } @@ -958,6 +1046,10 @@ struct DJConsole: View { } } @ViewBuilder private func playlistMenu(_ song: Song) -> some View { + Button("Song Info / Artist…") { editingTrack = song } + Button("Reinterpret Selected Names…") { reinterpretIDs = Set(contextTracks(song).map(\.id));reinterpreting = true }.disabled(system.namesBusy) + Button("Undo Last Name Reinterpretation") { Task { do { try await system.undoNameChanges() } catch { system.error = error.localizedDescription } } }.disabled(system.namesBusy) + Divider() Button("Load into Deck A") { system.load(song,into:"A") } Button("Load into Deck B") { system.load(song,into:"B") } Divider() @@ -993,7 +1085,7 @@ struct DJConsole: View { }.padding(24).frame(width:360) } private func loadSelected(_ deck: String) { if let selectedSong { system.load(selectedSong,into:deck) } else { system.status = "Select a DJ library track, then Load \(deck), or drag a DJ library track onto the deck." } } - private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [] } + private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [];updateFilteredTracks() } private func connectMIDI() { system.midi.currentValue = { [weak system] action in guard let system else { return nil } @@ -1061,6 +1153,7 @@ private struct DJDeckSurfaces { DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:94) } Text("LEVEL").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary) + Button { player.vocalReductionEnabled.toggle() } label: { Image(systemName:player.vocalReductionEnabled ? "mic.slash.fill" : "mic.fill") }.buttonStyle(.plain).foregroundStyle(player.vocalReductionEnabled ? color : .secondary).help("Centre vocal reduction: reduces centred stereo audio, including some instruments; mono tracks pass through").accessibilityLabel("Deck \(deck.letter) centre vocal reduction").accessibilityValue(player.vocalReductionEnabled ? "On" : "Off") } }.frame(width:110) } @@ -1092,36 +1185,59 @@ struct DJKnob: View { .accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) } } } -private struct DJPlatter: View { - @ObservedObject var player: Player +private struct DJPlatter: NSViewRepresentable { + let player: Player let letter: String let color: Color var markerEnabled = true - @State private var lastAngle: Double? - var body: some View { - TimelineView(.animation(minimumInterval:1.0/60,paused:!player.playing)) { _ in - let displayTime = player.displayTime - GeometryReader { geometry in - ZStack { - Circle().fill(Color.black.opacity(0.55)).overlay { Circle().stroke(color.opacity(0.4),lineWidth:3) } - ForEach([0.70,0.80,0.90],id:\.self) { scale in Circle().stroke(.white.opacity(0.06),lineWidth:1).scaleEffect(scale) } - ZStack { - Circle().fill(color.opacity(0.15)).frame(width:53,height:53) - Text(letter).font(.system(size:25,weight:.bold,design:.rounded)).foregroundStyle(color) - if markerEnabled { - Rectangle().fill(color).frame(width:5,height:26).offset(y:-geometry.size.height*0.37) - Circle().fill(.white).frame(width:5,height:5).offset(y:-geometry.size.height*0.45) - } - }.rotationEffect(.degrees(displayTime*200)) - }.contentShape(Circle()) - .gesture(DragGesture(minimumDistance:2).onChanged { drag in - let angle = atan2(Double(drag.location.y-geometry.size.height/2),Double(drag.location.x-geometry.size.width/2)) - if let previous = lastAngle { var delta = angle-previous;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta / (2 * .pi) * 2) } - lastAngle = angle - }.onEnded{_ in lastAngle = nil}) - }.accessibilityLabel("Deck \(letter) jog platter").accessibilityValue(player.playing ? "Rotating" : "Paused").help("Drag around the platter to jog (2 seconds per turn). Rotation follows source playback time.") + func makeNSView(context: Context) -> DJPlatterView { DJPlatterView() } + func updateNSView(_ view: DJPlatterView,context: Context) { + view.player = player;view.colour = NSColor(color);view.marker = markerEnabled;view.needsDisplay = true + view.setAccessibilityElement(true);view.setAccessibilityLabel("Deck \(letter) jog platter") + view.toolTip = "Drag around the platter to scrub (2 seconds per turn)" + } + static func dismantleNSView(_ view: DJPlatterView,coordinator: ()) { view.stopClock() } +} +private final class DJPlatterView: NSView { + weak var player: Player? + var colour = NSColor.systemCyan + var marker = true + private var clock: AnyObject? + private var timer: Timer? + private var angle: Double? + override var isFlipped: Bool { true } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + func stopClock() { timer?.invalidate();timer = nil;if #available(macOS 14,* ) { (clock as? CADisplayLink)?.invalidate() };clock = nil } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow();stopClock();guard window != nil else { return } + if #available(macOS 14,*) { + let link = displayLink(target:self,selector:#selector(frameTick));link.preferredFrameRateRange = CAFrameRateRange(minimum:30,maximum:60,preferred:60);link.add(to:.main,forMode:.common);clock = link + } else { + let tick = Timer(timeInterval:1/60,repeats:true) { [weak self] _ in self?.frameTick() };RunLoop.main.add(tick,forMode:.common);timer = tick } } + @objc private func frameTick() { if player?.playing == true,!isHiddenOrHasHiddenAncestor { needsDisplay = true } } + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + let side = min(bounds.width,bounds.height),centre = CGPoint(x:bounds.midX,y:bounds.midY) + let disc = CGRect(x:centre.x-side/2+2,y:centre.y-side/2+2,width:side-4,height:side-4) + context.setFillColor(NSColor.black.withAlphaComponent(0.55).cgColor);context.fillEllipse(in:disc) + context.setStrokeColor(colour.withAlphaComponent(0.4).cgColor);context.setLineWidth(3);context.strokeEllipse(in:disc) + context.setStrokeColor(NSColor.white.withAlphaComponent(0.06).cgColor);context.setLineWidth(1) + for scale in [0.70,0.80,0.90] { let radius = side*scale/2;context.strokeEllipse(in:CGRect(x:centre.x-radius,y:centre.y-radius,width:radius*2,height:radius*2)) } + guard marker else { return } + context.translateBy(x:centre.x,y:centre.y);context.rotate(by:(player?.displayTime ?? 0)*200 * .pi/180) + context.setFillColor(colour.cgColor);context.fill(CGRect(x:-2.5,y:-side*0.37-13,width:5,height:26)) + context.setFillColor(NSColor.white.cgColor);context.fillEllipse(in:CGRect(x:-2.5,y:-side*0.45-2.5,width:5,height:5)) + } + override func mouseDown(with event: NSEvent) { let p = convert(event.locationInWindow,from:nil);angle = atan2(p.y-bounds.midY,p.x-bounds.midX) } + override func mouseDragged(with event: NSEvent) { + let p = convert(event.locationInWindow,from:nil),next = atan2(p.y-bounds.midY,p.x-bounds.midX) + if let angle,let player { var delta = next-angle;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.scrub(to:player.time+delta / .pi) } + angle = next;needsDisplay = true + } + override func mouseUp(with event: NSEvent) { angle = nil;player?.endScrubbing() } + deinit { timer?.invalidate();if #available(macOS 14,*) { (clock as? CADisplayLink)?.invalidate() } } } struct DJWaveform: View { @State private var frozenStart: Double? @@ -1619,12 +1735,15 @@ enum DJBPM { return (60*rate/Double(bestLag)*10).rounded()/10 } static func analyse(_ url: URL,cancelled: () -> Bool = { false }) throws -> Double? { + try WaveformWorkload.shared.acquire(cancelled:cancelled) + defer { WaveformWorkload.shared.release() } let file = try AVAudioFile(forReading:url),format = file.processingFormat guard let buffer = AVAudioPCMBuffer(pcmFormat:format,frameCapacity:16384),format.channelCount > 0 else { return nil } let hop = max(1,Int(format.sampleRate/200)),limit = Int64(format.sampleRate*90) var envelope = [Double](),sum = 0.0,count = 0 while file.framePosition < min(file.length,limit) { if cancelled() { throw CancellationError() } + if WaveformWorkload.shared.playbackActive { Thread.sleep(forTimeInterval:0.001) } try file.read(into:buffer) guard buffer.frameLength > 0,let channels = buffer.floatChannelData else { break } for i in 0.. NativeVideoView { NativeVideoView() } + func updateNSView(_ view: NativeVideoView,context: Context) { + if view.image !== image { view.image = image;view.picture.contents = image } + view.picture.opacity = Float(opacity) + } +} +private final class NativeVideoView: NSView { + let picture = CALayer() + var image: CGImage? + override init(frame: NSRect) { + super.init(frame:frame);wantsLayer = true;layer?.backgroundColor = NSColor.black.cgColor + picture.contentsGravity = .resizeAspect;picture.magnificationFilter = .linear;picture.minificationFilter = .trilinear + picture.actions = ["contents":NSNull(),"bounds":NSNull(),"position":NSNull(),"opacity":NSNull()] + layer?.addSublayer(picture) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layout() { super.layout();picture.frame = bounds } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow();updateScale() } + override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties();updateScale() } + private func updateScale() { let scale = window?.backingScaleFactor ?? 1;layer?.contentsScale = scale;picture.contentsScale = scale;picture.frame = bounds } +} /// This is another view of the same published CGImage used by the Video window. /// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. private struct SidebarVideoMonitor: View { @@ -882,7 +922,7 @@ struct LibraryView: View { Text(source == "Library" ? "\(model.songs.count) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary) } Spacer() - TextField("Search karaoke music",text:$search).textFieldStyle(.roundedBorder).frame(maxWidth:260) + StudioSearchField(placeholder:"Search music",text:$search).frame(maxWidth:260) if source == "Library" { Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered) } @@ -925,7 +965,7 @@ struct LibraryView: View { .navigationSubtitle(model.currentVenueName) .toolbar { ToolbarItemGroup { - Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") } + if workspaceTab == "DJ Studio" { Button { studioSettingsModal = true } label: { Label("Settings",systemImage:"gearshape") } } if workspaceTab == "Karaoke" { Menu { Button("Import Files or Folder…") { importing = true } @@ -1490,7 +1530,7 @@ struct GoldStar: View { }.frame(width:size,height:size).accessibilityHidden(true) } } -private struct GoldRating: View { +struct GoldRating: View { @Binding var rating: Int var body: some View { HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } } diff --git a/KJamsClone/LibraryScanner.swift b/KJamsClone/LibraryScanner.swift index 39497b9..19f143f 100644 --- a/KJamsClone/LibraryScanner.swift +++ b/KJamsClone/LibraryScanner.swift @@ -134,3 +134,57 @@ enum WaveformBatch { progress(state);return state } } + +/// Admission control is read on workers; starting playback reduces future admissions. +final class WaveformWorkload: @unchecked Sendable { + static let shared = WaveformWorkload() + private let lock = NSCondition() + private var playing = Set() + private var active = 0 + private var allowed: Int { playing.isEmpty ? min(2,max(1,ProcessInfo.processInfo.activeProcessorCount/2)) : 1 } + func setPlaying(_ active: Bool,id: UUID) { lock.lock();defer { lock.unlock() };if active { playing.insert(id) } else { playing.remove(id) };lock.broadcast() } + var workers: Int { lock.lock();defer { lock.unlock() };return allowed } + var playbackActive: Bool { lock.lock();defer { lock.unlock() };return !playing.isEmpty } + func acquire(cancelled: () -> Bool) throws { + lock.lock();defer { lock.unlock() } + while active >= allowed { + if cancelled() { throw CancellationError() } + _ = lock.wait(until:Date().addingTimeInterval(0.02)) + } + if cancelled() { throw CancellationError() };active += 1 + } + func release() { lock.lock();active -= 1;lock.broadcast();lock.unlock() } +} +extension WaveformBatch { + static func runConcurrent(songs: [Song],locations: [MediaLocation],cache: URL,force: Bool = false,limit: @escaping @Sendable () -> Int = { WaveformWorkload.shared.workers },progress: (WaveformJobProgress) -> Void = { _ in }) async -> WaveformJobProgress { + var ids = Set(),groups = [[Song]](),indices = [String:Int]() + for song in songs where ids.insert(song.id).inserted { + let key = URL(fileURLWithPath:song.audio).standardizedFileURL.path + if let index = indices[key] { groups[index].append(song) } + else { indices[key] = groups.count;groups.append([song]) } + } + var state = WaveformJobProgress(total:ids.count),next = 0,active = 0,lastReport = Date.distantPast + await withTaskGroup(of:WaveformJobProgress.self) { group in + while next < groups.count || active > 0 { + if Task.isCancelled { group.cancelAll();state.cancelled = true } + let allowed = min(2,max(1,limit())) + while !Task.isCancelled,active < allowed,next < groups.count { + let tracks = groups[next];next += 1;active += 1 + state.current = tracks.first?.title ?? "Preparing waveforms…" + group.addTask(priority:.utility) { + run(songs:tracks,locations:locations,cache:cache,force:force,cancelled:{Task.isCancelled}) + } + } + guard active > 0,let result = await group.next() else { break } + active -= 1 + state.processed += result.processed;state.generated += result.generated;state.cached += result.cached + state.skipped += result.skipped;state.issues += result.issues;state.cancelled = state.cancelled || result.cancelled + state.messages.append(contentsOf:result.messages.prefix(max(0,100-state.messages.count))) + if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() } + } + } + state.finished = true + state.current = state.cancelled ? "Cancelled · completed waveforms kept" : state.issues > 0 ? "Waveform analysis finished with issues" : "Waveforms ready" + progress(state);return state + } +} diff --git a/KJamsClone/Metadata.swift b/KJamsClone/Metadata.swift index dc51026..fc8fc87 100644 --- a/KJamsClone/Metadata.swift +++ b/KJamsClone/Metadata.swift @@ -360,7 +360,15 @@ struct NameTemplate { } } -extension AppModel { +@MainActor protocol NameReinterpretationModel: ObservableObject { + var songs: [Song] { get } + var library: Library? { get } + var namesBusy: Bool { get } + func refresh() throws + func perform(_ action: () throws -> Void) + func applyNameChanges(_ changes: [NameChange]) async throws +} +extension NameReinterpretationModel { func savedNameTemplates() -> [String] { let saved = ((try? library?.setting(NameTemplate.settingsKey)) ?? "").components(separatedBy:"\n").filter{!$0.trimmingCharacters(in:.whitespaces).isEmpty} return Array(Set(NameTemplate.presets+saved)).sorted() @@ -371,6 +379,8 @@ extension AppModel { for (index,template) in templates.enumerated() { do { _ = try NameTemplate(template) } catch { throw LibraryError.message("Line \(index+1): \(error.localizedDescription)") } } try library?.setSetting(NameTemplate.settingsKey,templates.joined(separator:"\n")) } +} +extension AppModel: NameReinterpretationModel { func applyNameChanges(_ changes:[NameChange]) async throws { guard !namesBusy,!isImporting,!movingMedia,scanningSource == nil,!generatingWaveforms,!tagsBusy else { throw LibraryError.message("Finish the current library job first") } namesBusy = true;defer { namesBusy = false } @@ -389,8 +399,8 @@ extension AppModel { } } -struct ReinterpretNamesSheet: View { - @ObservedObject var model: AppModel +struct ReinterpretNamesSheet: View { + @ObservedObject var model: Model let ids: Set @Environment(\.dismiss) private var dismiss @State private var template = "L-T - R - S" @@ -482,3 +492,22 @@ extension FocusedValues { set { self[NameReinterpretationKey.self] = newValue } } } + +extension DJSystem: NameReinterpretationModel { + func applyNameChanges(_ changes: [NameChange]) async throws { + guard !namesBusy,!importing,!generatingWaveforms else { throw LibraryError.message("Finish the current library job first") } + namesBusy = true;defer { namesBusy = false } + let database = root.appendingPathComponent("Library.sqlite") + _ = try await Task.detached(priority:.utility) { try Library(url:database).reinterpretNames(changes) }.value + try refresh() + for deck in [a,b] { if let song = songs.first(where:{$0.id == deck.song?.id}) { deck.updateTrackMetadata(song) } } + } + func undoNameChanges() async throws { + guard !namesBusy,!importing,!generatingWaveforms else { throw LibraryError.message("Finish the current library job first") } + namesBusy = true;defer { namesBusy = false } + let database = root.appendingPathComponent("Library.sqlite") + _ = try await Task.detached(priority:.utility) { try Library(url:database).undoNameReinterpretation() }.value + try refresh() + for deck in [a,b] { if let song = songs.first(where:{$0.id == deck.song?.id}) { deck.updateTrackMetadata(song) } } + } +} diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 24183aa..be46576 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -18,9 +18,46 @@ import CoreAudio var nudgeStep: Double = 0.05 @Published private(set) var cuePosition: Double = 0 @Published private(set) var hotCues: [Double?] = [nil,nil,nil,nil] - @Published private(set) var loopIn: Double? - @Published private(set) var loopOut: Double? - @Published var loopEnabled = false + @Published private(set) var loopIn: Double? { didSet { if loopEnabled { prepareNativeLoop() } } } + @Published private(set) var loopOut: Double? { didSet { if loopEnabled { prepareNativeLoop() } } } + @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) } + } + } } + private var loopBufferTask: Task? + private var loopBuffer: AVAudioPCMBuffer? + private var preparedLoop: ClosedRange? + private(set) var nativeLoopActive = false + private func loopPosition(_ position: Double) -> Double { + guard nativeLoopActive,let range = preparedLoop,position >= range.upperBound else { return position } + return range.lowerBound+(position-range.upperBound).truncatingRemainder(dividingBy:range.upperBound-range.lowerBound) + } + 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 } + let songID = currentSongID + loopBufferTask = Task { @MainActor [weak self] in + let worker = Task.detached(priority:.userInitiated) { () throws -> 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();return buffer + } + do { + let buffer = 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.schedule(at:position < end ? position : start,play:resume,notifyPlayback:false) + } catch { } + } + } var isShowScreen: Bool { slideImage != nil } var canJog: Bool { currentSongID != nil && duration > 0 } /// Read the audio clock for display frames without publishing the entire player at 60 Hz. @@ -30,7 +67,7 @@ import CoreAudio let now = mach_absolute_time() let elapsed = render.isHostTimeValid && now >= render.hostTime ? min(0.1,AVAudioTime.seconds(forHostTime:now-render.hostTime)) : 0 let position = anchor+Double(current.sampleTime)/file.processingFormat.sampleRate+elapsed*tempo - return min(duration,max(0,position)) + return min(duration,max(0,loopPosition(position))) } func stopDeck() { guard canJog else { return } @@ -45,7 +82,7 @@ import CoreAudio private func pauseDeck() { cancelPendingPlayback() if slideImage != nil,playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart);slideAnchor = time } - else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,anchor+Double(current.sampleTime)/file.processingFormat.sampleRate)) } + else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,loopPosition(anchor+Double(current.sampleTime)/file.processingFormat.sampleRate))) } node.pause();playing = false;timer?.invalidate();timer = nil } func hotCue(_ index: Int,clear: Bool = false) { @@ -163,6 +200,22 @@ import CoreAudio @Published private(set) var leftLevel: Float = 0 @Published private(set) var rightLevel: Float = 0 private let tone = AVAudioUnitEQ(numberOfBands:4) + private let graphicEQ = AVAudioUnitEQ(numberOfBands:10) + private let vocalReduction = AVAudioUnitEffect(audioComponentDescription:DJVocalReductionUnit.component) + @Published var vocalReductionEnabled = false { didSet { (vocalReduction.auAudioUnit as? DJVocalReductionUnit)?.setAmount(vocalReductionEnabled ? 1 : 0) } } + private(set) var graphicGains = [Double](repeating:0,count:10) + private(set) var graphicEnabled = false + func applyGraphicEQ(_ gains: [Double],enabled: Bool) { + graphicGains = (0..<10).map { gains.indices.contains($0) && gains[$0].isFinite ? min(12,max(-12,gains[$0])) : 0 } + graphicEnabled = enabled;graphicEQ.bypass = !enabled + let frequencies: [Double] = [31,62,125,250,500,1000,2000,4000,8000,16000] + let rate = file?.processingFormat.sampleRate ?? 44100 + for i in 0..<10 { + let band = graphicEQ.bands[i];band.filterType = .parametric + band.frequency = Float(min(rate*0.45,frequencies[i]));band.bandwidth = 1 + band.gain = Float(graphicGains[i]);band.bypass = false + } + } private let meter = AudioLevelMeter() private var metering = false @Published private(set) var waveformLoading = false @@ -174,14 +227,16 @@ import CoreAudio var sourceAudioURL: URL? { audioURL } private var waveformTask: Task? private var waveformGeneration = UUID() - @Published var playing = false + private let workloadID = UUID() + @Published var playing = false { didSet { if playing != oldValue { WaveformWorkload.shared.setPlaying(playing,id:workloadID) } } } + deinit { WaveformWorkload.shared.setPlaying(false,id:workloadID) } @Published var time: Double = 0 @Published var duration: Double = 0 @Published var title = "Select a song" @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume); saveMix() } } @Published var repeating = false - @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } - @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100);effect.bypass = pitch == 0 && tempo == 1 } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo);effect.bypass = pitch == 0 && tempo == 1 } } @Published var graphicsOffset: Double = 0 { didSet { if graphicsOffset != oldValue { invalidateGraphics();render() } } } private(set) var currentSongID: Int64? var onFinished: (() -> Void)? @@ -197,7 +252,7 @@ import CoreAudio init(waveformCacheDirectory: URL? = nil, persistsMix: Bool = true) { self.persistsMix = persistsMix self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms") - engine.attach(node);engine.attach(effect);engine.attach(tone) + engine.attach(node);engine.attach(effect);engine.attach(tone);engine.attach(graphicEQ);engine.attach(vocalReduction);graphicEQ.bypass = true if persistsMix,let data = try? Data(contentsOf:mixURL),let mix = try? JSONDecoder().decode(GlobalMix.self,from:data) { volume = min(1,max(0,mix.volume));lowGain = min(12,max(-12,mix.low));midGain = min(12,max(-12,mix.mid));highGain = min(12,max(-12,mix.high));filter = min(1,max(-1,mix.filter));toneEnabled = mix.enabled } @@ -219,12 +274,14 @@ import CoreAudio guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } engine.connect(node,to:effect,format:loaded.processingFormat) engine.connect(effect,to:tone,format:loaded.processingFormat) - engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) + engine.connect(tone,to:graphicEQ,format:loaded.processingFormat) + engine.connect(graphicEQ,to:vocalReduction,format:loaded.processingFormat) + engine.connect(vocalReduction,to:engine.mainMixerNode,format:loaded.processingFormat) file = loaded; cdg = graphics; decoder = CDGDecoder();prepareGraphicsCheckpoints() duration = Double(loaded.length)/loaded.processingFormat.sampleRate title = song.title; currentSongID = song.id pitch = preference.pitch; tempo = preference.tempo - updateTone() + updateTone();applyGraphicEQ(graphicGains,enabled:graphicEnabled) let meter = self.meter engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } metering = true @@ -289,6 +346,16 @@ import CoreAudio let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) anchor = Double(start)/rate; time = anchor guard start < file.length else { playing = false; render(); return } + nativeLoopActive = false + if loopEnabled,let range = preparedLoop,range.lowerBound == loopIn,range.upperBound == loopOut,let loopBuffer,seconds < range.upperBound { + let endFrame = AVAudioFramePosition(range.upperBound*rate) + 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 + if play { if notifyPlayback { startNodePlayback() } else { node.play() } } + playing = play;render();return + } node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in Task { @MainActor in guard let self, self.generation == token else { return } @@ -330,7 +397,7 @@ import CoreAudio generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } meter.reset(); leftLevel = 0; rightLevel = 0 - engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone);engine.disconnectNodeOutput(graphicEQ);engine.disconnectNodeOutput(vocalReduction) file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" } var audibleScrubbing = false @@ -346,10 +413,7 @@ import CoreAudio let target = min(duration,max(0,value)),now = ProcessInfo.processInfo.systemUptime scrubTarget = target // Do not starve HAL by stopping/restarting the node on every mouse event. - if now-lastScrubAudio < 0.12 { - if !playing { time = target;render() } - return - } + if now-lastScrubAudio < 0.06 { return } lastScrubAudio = now if playing { seek(target);return } scrubPreviewTask?.cancel() @@ -429,9 +493,9 @@ import CoreAudio let left = playing ? levels.0 : 0,right = playing ? levels.1 : 0 if leftLevel != left { leftLevel = left };if rightLevel != right { rightLevel = right } if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { - let next = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) + let next = min(duration,max(0,loopPosition(anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate))) if abs(time-next) > 0.0001 { time = next } - if loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return } + if !nativeLoopActive,loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return } } if !fading { setFade(autoFade && fadeSeconds > 0 && duration > 0 ? min(1,max(0,(duration-time)/max(0.5,tempo)/fadeSeconds)) : 1) } render() @@ -461,6 +525,8 @@ enum AudioWaveform { } static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis { guard bins > 0 else { return .init(levels:[],bands:[]) } + try WaveformWorkload.shared.acquire(cancelled:cancelled) + defer { WaveformWorkload.shared.release() } let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } var energy = [Double](repeating: 0, count: bins) @@ -473,23 +539,37 @@ enum AudioWaveform { var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows while position < file.length { if cancelled() { throw CancellationError() } + if WaveformWorkload.shared.playbackActive { Thread.sleep(forTimeInterval:0.001) } try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) let frames = Int(buffer.frameLength) guard frames > 0, let data = buffer.floatChannelData else { break } - for frame in 0.. Float { guard lock.try() else { return 0 };defer { lock.unlock() };return amount } +} +final class DJVocalReductionUnit: AUAudioUnit { + private var input: AUAudioUnitBus! + private var output: AUAudioUnitBus! + private var inputs: AUAudioUnitBusArray! + private var outputs: AUAudioUnitBusArray! + private let control = DJVocalControl() + static let component: AudioComponentDescription = { + let description = AudioComponentDescription(componentType:kAudioUnitType_Effect,componentSubType:0x6B6A7672,componentManufacturer:0x4B4A4D58,componentFlags:0,componentFlagsMask:0) + AUAudioUnit.registerSubclass(DJVocalReductionUnit.self,as:description,name:"KJMix: Centre Vocal Reduction",version:1) + return description + }() + override init(componentDescription: AudioComponentDescription,options: AudioComponentInstantiationOptions = []) throws { + try super.init(componentDescription:componentDescription,options:options) + let format = AVAudioFormat(standardFormatWithSampleRate:44100,channels:2)! + input = try AUAudioUnitBus(format:format);output = try AUAudioUnitBus(format:format) + inputs = AUAudioUnitBusArray(audioUnit:self,busType:.input,busses:[input]) + outputs = AUAudioUnitBusArray(audioUnit:self,busType:.output,busses:[output]) + maximumFramesToRender = 4096 + } + override var inputBusses: AUAudioUnitBusArray { inputs } + override var outputBusses: AUAudioUnitBusArray { outputs } + func setAmount(_ value: Float) { control.set(min(1,max(0,value))) } + static func reduce(left: Float,right: Float,amount: Float) -> (Float,Float) { + let side = (left-right)*0.5 + return (left*(1-amount)+side*amount,right*(1-amount)+side*amount) + } + override var internalRenderBlock: AUInternalRenderBlock { + let control = self.control + return { flags,time,frames,_,data,_,pull in + guard let pull else { return kAudioUnitErr_NoConnection } + let result = pull(flags,time,frames,0,data) + guard result == noErr else { return result } + let amount = control.read() + guard amount > 0 else { return noErr } + let buffers = UnsafeMutableAudioBufferListPointer(data) + if buffers.count == 2,let l = buffers[0].mData?.assumingMemoryBound(to:Float.self),let r = buffers[1].mData?.assumingMemoryBound(to:Float.self) { + for i in 0.. [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) + if let bitmap = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:bitmap);try bitmap.representation(using:.png,properties:[:])?.write(to:URL(fileURLWithPath:"/private/tmp/kjmix-20261106-performance/sidebar.png")) } + func click(_ identifier: String) async throws { + var visited = Set() + func find(_ node: NSObject,depth: Int = 0) -> NSObject? { + guard depth < 40,visited.insert(ObjectIdentifier(node)).inserted else { return nil } + if node.responds(to:NSSelectorFromString("accessibilityIdentifier")),node.value(forKey:"accessibilityIdentifier") as? String == identifier { return node } + if node.responds(to:NSSelectorFromString("accessibilityChildren")),let children = node.value(forKey:"accessibilityChildren") as? [NSObject] { + for child in children { if let found = find(child,depth:depth+1) { return found } } + } + return nil + } + host.layoutSubtreeIfNeeded() + let button = try XCTUnwrap(find(host),"Find the actual button by accessibility identifier instead of assuming a fixed sidebar row position") + let frame = try XCTUnwrap(button.value(forKey:"accessibilityFrame") as? NSValue).rectValue + let point = window.convertPoint(fromScreen:NSPoint(x:frame.midX,y:frame.midY)) 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) } try await Task.sleep(nanoseconds:90_000_000) } - try await click(24,336) + model.expandedSingerIDs.remove(singer) + try await Task.sleep(nanoseconds:50_000_000) + try await click("singer.expand.\(singer)") XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer") - try await click(100,153);XCTAssertEqual(model.activeSection,"Rotation") - try await click(100,116);XCTAssertEqual(model.activeSection,"Library") + try await click("nav.Rotation");XCTAssertEqual(model.activeSection,"Rotation") + try await click("nav.Library");XCTAssertEqual(model.activeSection,"Library") XCTAssertTrue(model.expandedSingerIDs.contains(singer)) - try await click(24,336) + try await click("singer.expand.\(singer)") XCTAssertFalse(model.expandedSingerIDs.contains(singer));XCTAssertNil(model.error) } @@ -2650,7 +2662,7 @@ extension KJamsCloneTests { XCTAssertFalse(player.playing) XCTAssertTrue(player.audioPlaybackStarted,"Paused scrubbing must render an audio grain") player.scrub(to:0.2) - XCTAssertEqual(player.time,0.2,accuracy:0.001) + XCTAssertEqual(player.time,0.5,accuracy:0.001,"Display stays with the audible grain until the next audio update") player.endScrubbing() XCTAssertFalse(player.audioPlaybackStarted);XCTAssertFalse(player.playing) XCTAssertEqual(player.time,0.2,accuracy:0.001) @@ -2742,4 +2754,122 @@ extension KJamsCloneTests { XCTAssertEqual(player.time,0.69,accuracy:0.001) } + func testConcurrentWaveformBatchSavesUniquePathsAndReusesDuplicates() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav")) + var songs = [Song]() + for i in 0..<4 { + let audio = root.appendingPathComponent("Track\(i).wav") + try FileManager.default.copyItem(at:fixture,to:audio) + songs.append(Song(id:Int64(i),title:"Track",audio:audio.path,cdg:nil,rating:0,plays:0)) + } + var duplicate = songs[0];duplicate.id = 10;songs.append(duplicate) + let cache = root.appendingPathComponent("Cache") + let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2}) + XCTAssertEqual(result.generated,4);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.processed,5);XCTAssertEqual(result.issues,0) + let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1}) + XCTAssertEqual(reused.cached,5);XCTAssertEqual(reused.generated,0) + } + @MainActor func testWaveformAdmissionDropsToOneDuringPlayback() throws { + let policy = WaveformWorkload.shared,id = UUID() + policy.setPlaying(true,id:id) + XCTAssertEqual(policy.workers,1) + policy.setPlaying(false,id:id) + XCTAssertLessThanOrEqual(policy.workers,2) + } + @MainActor func testSharedDJGraphicEQAppliesAcrossLoadsAndPersists() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let system = DJSystem(storageURL:root) + defer { system.a.unload();system.b.unload() } + system.chooseGraphicPreset("Rock") + XCTAssertTrue(system.a.player.graphicEnabled) + XCTAssertEqual(system.a.player.graphicGains,system.b.player.graphicGains) + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav")) + let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0) + system.load(song,into:"A") + XCTAssertEqual(system.a.player.graphicGains,DJGraphicEQSettings.presets.first(where:{$0.0 == "Rock"})?.1) + system.graphicEQ.enabled = false + XCTAssertFalse(system.a.player.graphicEnabled);XCTAssertFalse(system.b.player.graphicEnabled) + try await Task.sleep(nanoseconds:300_000_000) + let reopened = DJSystem(storageURL:root) + XCTAssertEqual(reopened.graphicEQ,system.graphicEQ) + } + + func testCentreVocalReductionRemovesCentreAndPreservesSideSignal() { + let centred = DJVocalReductionUnit.reduce(left:0.8,right:0.8,amount:1) + XCTAssertEqual(centred.0,0,accuracy:0.0001);XCTAssertEqual(centred.1,0,accuracy:0.0001) + let side = DJVocalReductionUnit.reduce(left:0.8,right:-0.8,amount:1) + XCTAssertEqual(side.0,0.8,accuracy:0.0001);XCTAssertEqual(side.1,0.8,accuracy:0.0001) + let bypass = DJVocalReductionUnit.reduce(left:0.6,right:0.2,amount:0) + XCTAssertEqual(bypass.0,0.6,accuracy:0.0001);XCTAssertEqual(bypass.1,0.2,accuracy:0.0001) + } + @MainActor func testDJNameReinterpretationUpdatesArtistAndUndo() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let system = DJSystem(storageURL:root) + let id = try XCTUnwrap(system.library?.add(title:"Example Artist - Example Song",audio:"/missing/example.mp3",cdg:nil)) + try system.refresh() + let preview = try NameTemplate.preview(system.songs,template:"R - S",source:.song,emDashes:false,underscores:true) + try await system.applyNameChanges(preview.compactMap(\.change)) + XCTAssertEqual(system.songs.first?.artist,"Example Artist");XCTAssertEqual(system.songs.first?.title,"Example Song") + try await system.undoNameChanges() + XCTAssertEqual(system.songs.first?.id,id);XCTAssertEqual(system.songs.first?.title,"Example Artist - Example Song") + } + + @MainActor func testCentreVocalReductionProcessesLiveStereoAudio() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2)) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:88200));buffer.frameLength = 88200 + let data = try XCTUnwrap(buffer.floatChannelData) + for i in 0..<88200 { let value = Float(sin(Double(i)*2 * .pi*440/44100))*0.3;data[0][i] = value;data[1][i] = value } + let url = root.appendingPathComponent("Centred.wav") + do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) } + let player = Player(waveformCacheDirectory:root,persistsMix:false) + defer { player.stop() } + player.autoFade = false;player.volume = 0.8 + try player.load(Song(id:1,title:"Centred",audio:url.path,cdg:nil,rating:0,plays:0)) + for _ in 0..<50 where player.scrubOutputLevel <= 0.01 { try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertGreaterThan(player.scrubOutputLevel,0.01) + player.vocalReductionEnabled = true + for _ in 0..<70 where player.scrubOutputLevel >= 0.001 { try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertLessThan(player.scrubOutputLevel,0.001) + XCTAssertTrue(player.playing) + player.vocalReductionEnabled = false + for _ in 0..<70 where player.scrubOutputLevel <= 0.01 { try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertGreaterThan(player.scrubOutputLevel,0.01) + } + + @MainActor func testShortLoopRunsOnAudioNodeAndClockWraps() 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.wav")) + let player = Player(waveformCacheDirectory:root,persistsMix:false) + defer { player.stop() } + player.autoFade = false + try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false) + player.defineLoop(start:0.2,end:0.4) + for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertTrue(player.nativeLoopActive) + player.seek(0.25);player.toggle() + for _ in 0..<30 { + try await Task.sleep(nanoseconds:20_000_000) + XCTAssertTrue(player.audioPlaybackStarted);XCTAssertTrue(player.nativeLoopActive) + XCTAssertGreaterThanOrEqual(player.displayTime,0.2);XCTAssertLessThan(player.displayTime,0.401) + } + player.defineLoop(start:0.2,end:0.3) + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertTrue(player.nativeLoopActive);XCTAssertLessThan(player.displayTime,0.301) + let exit = player.displayTime + player.loopEnabled = false + XCTAssertFalse(player.nativeLoopActive);XCTAssertTrue(player.playing) + XCTAssertEqual(player.time,exit,accuracy:0.03) + try await Task.sleep(nanoseconds:180_000_000) + XCTAssertGreaterThan(player.displayTime,0.3,"Loop Off must continue immediately past the previous loop end") + } + } diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 25e9aa6..07179de 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -93,3 +93,21 @@ The DJ library advances to the next page when a user scroll reaches the bottom. DJ waveform generation opens a modal with a coloured progress bar, percentage ring, counts and Cancel. The results stay visible until Done; any analysis issues are listed in the modal. DJ library selection supports Command-click for separate tracks, Shift-click for ranges, and Command-A or right-click Select All Songs for all matching tracks across pages. The queue toolbar action adds the selected tracks in library order. Right-click selected tracks to generate or regenerate their saved waveforms or add the selection to the DJ queue. + +## Library and audio responsiveness + +DJ tracks appear in a native table with resizable Title, Artist, Album, Time and Rating columns. Favourite stars and five-star ratings are independent and saved in the DJ library. Right-click Song Info / Artist to edit metadata, or Reinterpret Selected Names to use the same preview/template tools as Karaoke Studio; Undo restores the last DJ name change. + +Both studios use the same search field with a clear X. Filtered DJ rows are cached until search/library/playlist/favourites change; fader events do not rebuild the library table. Unchanged drag titles no longer redraw on every selection update. Platters use native display callbacks instead of SwiftUI frame-by-frame animation. + +Waveform analysis processes contiguous bin segments rather than doing division for every sample. Batches admit at most two background workers, dropping to one while any player is playing; existing workers finish their current files, and decode blocks yield during playback. Original cache validity and cancellation rules remain. A local bundled-WAV benchmark measured about 3× faster analysis, with matching levels; this is not a guarantee for other media or drives. + +Equaliser opens a shared ten-band graphic EQ for both DJ decks and headphone preview. Flat, Rock, Pop, Jazz, Vocal, Bass Boost and Treble Boost presets are editable, with bypass and automatic persistence. Gain changes apply without restarting playback. + +Each deck also has a microphone toggle beside its level fader for basic centre-vocal reduction. It runs in the audio render path and can be switched during playback. Centred instruments are reduced too; mono is passed through, and stereo vocals may remain. This is centre cancellation, not AI source separation. + +Ordinary loops are prepared on a worker and scheduled with the audio node's looping buffer, rather than restarting from a UI timer. Source display time wraps on that same audio clock. Loop buffers are capped at 64 MB; longer manual loops retain the streaming/timer fallback. At normal speed with no pitch shift the time-pitch unit is bypassed to reduce processing overhead. During scrubbing, the waveform follows accepted audio positions, rather than showing a newer mouse target before that audio is scheduled. + +The waveform/BPM admission limit is shared across imports, on-load analysis and batch jobs, so separate analysis paths cannot each start their own pair of workers. Playing audio reduces that global limit to one; reads yield between blocks. Neutral time-pitch processing is bypassed. + +Loop Off cancels scheduled repeating audio immediately and resumes normal playback at the current loop position. The TV/video view presents original decoded frames directly in a display-resolution-backed layer with aspect-fit scaling, and opens at 1280×720 by default. CDG source graphics remain their original fixed resolution; scaling cannot recover missing HD detail. Settings uses a non-wrapping Workspace selector; the Karaoke Studio toolbar omits the Settings button.