performance updates

This commit is contained in:
Mark Gregory
2026-11-06 08:32:48 +00:00
parent 9e610bd6f2
commit 376f027f12
7 changed files with 127 additions and 49 deletions
+71 -25
View File
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
import QuartzCore
struct DJLoopDrag {
enum Mode { case seek, start, end, move, create }
@@ -155,6 +156,7 @@ struct DJNativeSpectrum: NSViewRepresentable {
let showTimeGrid: Bool
let spectralWaveforms: Bool
func makeNSView(context: Context) -> DJNativeSpectrumView { DJNativeSpectrumView() }
static func dismantleNSView(_ view: DJNativeSpectrumView,coordinator: ()) { view.stopDisplayClock() }
func updateNSView(_ view: DJNativeSpectrumView,context: Context) {
view.player = player;view.bpm = bpm;view.color = color;view.span = seconds
view.label = label;view.showTimeGrid = showTimeGrid;view.spectralWaveforms = spectralWaveforms
@@ -170,6 +172,10 @@ final class DJNativeSpectrumView: DJWaveMouseView {
var label = ""
var showTimeGrid = true,spectralWaveforms = true
private var clock: Timer?
private var displayClock: AnyObject?
private var tiles = [Int:CGImage]()
private var tileKey = ""
private(set) var waveformTileBuildCount = 0
private var frozenViewport: Double?
private var hoverX: Double?
private var tracking: NSTrackingArea?
@@ -178,17 +184,32 @@ final class DJNativeSpectrumView: DJWaveMouseView {
freeze = { [weak self] start in self?.frozenViewport = start;self?.needsDisplay = true }
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func stopDisplayClock() {
clock?.invalidate();clock = nil
if #available(macOS 14.0,*) { (displayClock as? CADisplayLink)?.invalidate() }
displayClock = nil
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow();clock?.invalidate();clock = nil
if window != nil {
let timer = Timer(timeInterval:1.0/60,repeats:true) { [weak self] _ in
guard let self,self.player?.playing == true,!self.isHiddenOrHasHiddenAncestor else { return }
self.needsDisplay = true
}
super.viewDidMoveToWindow();stopDisplayClock()
guard window != nil else { return }
if #available(macOS 14.0,*) {
let link = displayLink(target:self,selector:#selector(displayFrame(_:)))
link.preferredFrameRateRange = CAFrameRateRange(minimum:30,maximum:120,preferred:60)
link.add(to:.main,forMode:.common);displayClock = link
} else {
let timer = Timer(timeInterval:1.0/60,repeats:true) { [weak self] _ in self?.refreshFrame() }
RunLoop.main.add(timer,forMode:.common);clock = timer
}
}
deinit { clock?.invalidate() }
@available(macOS 14.0,*) @objc private func displayFrame(_ link: CADisplayLink) { refreshFrame() }
private func refreshFrame() {
guard player?.playing == true,!isHiddenOrHasHiddenAncestor else { return }
needsDisplay = true
}
deinit {
clock?.invalidate()
if #available(macOS 14.0,*) { (displayClock as? CADisplayLink)?.invalidate() }
}
override func updateTrackingAreas() {
super.updateTrackingAreas();if let tracking { removeTrackingArea(tracking) }
let area = NSTrackingArea(rect:.zero,options:[.activeInKeyWindow,.inVisibleRect,.mouseMoved,.mouseEnteredAndExited],owner:self)
@@ -210,24 +231,7 @@ final class DJNativeSpectrumView: DJWaveMouseView {
NSColor.white.withAlphaComponent(0.15).setFill()
for tick in 0...8 { NSRect(x:Double(tick)/8*width,y:0,width:0.5,height:height).fill() }
}
let paths = (0..<32).map { _ in NSBezierPath() },count = max(1,Int(width/2))
for i in 0..<count {
let seconds = start+Double(i)/Double(count)*span
guard seconds >= 0,seconds < player.duration else { continue }
let sample = min(Double(player.waveform.count-1),seconds/player.duration*Double(player.waveform.count))
let index = Int(sample),next = min(player.waveform.count-1,index+1),fraction = sample-Double(index)
let amplitude = Double(player.waveform[index])*(1-fraction)+Double(player.waveform[next])*fraction
let barHeight = max(1,amplitude*height*0.85)
var bucket = 0
if spectralWaveforms,player.waveformBands.indices.contains(index) {
let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high)
bucket = min(31,max(0,Int((Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72)*31)))
}
paths[bucket].appendRect(NSRect(x:Double(i)*width/Double(count),y:(height-barHeight)/2,width:1.5,height:barHeight))
}
for bucket in paths.indices where !paths[bucket].isEmpty {
(spectralWaveforms ? NSColor(calibratedHue:Double(bucket)/31,saturation:0.85,brightness:0.95,alpha:1) : color).setFill();paths[bucket].fill()
}
drawWaveform(player,start:start,width:width,height:height)
if let bpm {
for beat in DJWaveMarkers.beats(start:start,span:span,bpm:bpm,width:width) {
let x = (Double(beat)*60/bpm-start)/span*width,major = beat % 4 == 0
@@ -258,6 +262,48 @@ final class DJNativeSpectrumView: DJWaveMouseView {
text(String(format:"%d:%05.2f",Int(seconds)/60,seconds.truncatingRemainder(dividingBy:60)),at:NSPoint(x:max(0,width-65),y:max(0,height-14)),color:.white)
}
}
private func drawWaveform(_ player: Player,start: Double,width: Double,height: Double) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
let scale = window?.backingScaleFactor ?? 2
let key = "\(player.waveformRevision):\(width):\(height):\(scale):\(span):\(spectralWaveforms):\(color)"
if tileKey != key { tiles.removeAll();tileKey = key }
let first = Int(floor(start/span))
tiles = tiles.filter { (first...first+2).contains($0.key) }
context.interpolationQuality = .high
for tile in first...first+2 {
if tiles[tile] == nil { tiles[tile] = waveformTile(player,tile:tile,width:width,height:height,scale:scale) }
if let image = tiles[tile] {
let x = (Double(tile)*span-start)/span*width
context.draw(image,in:CGRect(x:x,y:0,width:width,height:height))
}
}
}
private func waveformTile(_ player: Player,tile: Int,width: Double,height: Double,scale: Double) -> CGImage? {
guard let context = CGContext(data:nil,width:max(1,Int(ceil(width*scale))),height:max(1,Int(ceil(height*scale))),bitsPerComponent:8,bytesPerRow:0,space:CGColorSpaceCreateDeviceRGB(),bitmapInfo:CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
context.scaleBy(x:scale,y:scale)
let paths = (0..<32).map { _ in CGMutablePath() },count = max(1,Int(width/2))
for i in 0..<count {
let seconds = Double(tile)*span+Double(i)/Double(count)*span
guard seconds >= 0,seconds < player.duration else { continue }
let sample = min(Double(player.waveform.count-1),seconds/player.duration*Double(player.waveform.count))
let index = Int(sample),next = min(player.waveform.count-1,index+1),fraction = sample-Double(index)
let amplitude = Double(player.waveform[index])*(1-fraction)+Double(player.waveform[next])*fraction
let barHeight = max(1,amplitude*height*0.85)
var bucket = 0
if spectralWaveforms,player.waveformBands.indices.contains(index) {
let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high)
bucket = min(31,max(0,Int((Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72)*31)))
}
paths[bucket].addRect(CGRect(x:Double(i)*width/Double(count),y:(height-barHeight)/2,width:1.5,height:barHeight))
}
for bucket in paths.indices where !paths[bucket].isEmpty {
let shade = spectralWaveforms ? NSColor(calibratedHue:Double(bucket)/31,saturation:0.85,brightness:0.95,alpha:1) : color
context.setFillColor(shade.cgColor);context.addPath(paths[bucket]);context.fillPath()
}
let image = context.makeImage()
if image != nil { waveformTileBuildCount += 1 }
return image
}
private func text(_ value: String,at point: NSPoint,color: NSColor) {
(value as NSString).draw(at:point,withAttributes:[.font:NSFont.monospacedSystemFont(ofSize:9,weight:.semibold),.foregroundColor:color])
}
+3
View File
@@ -796,6 +796,9 @@ struct DJConsole: View {
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Load into Deck A") { system.load(song,into:"A") }
Button("Load into Deck B") { system.load(song,into:"B") }
Divider()
Button("Add to DJ Queue") { system.automix.enqueue([song]) }
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist {
+20 -21
View File
@@ -745,22 +745,19 @@ struct LibraryView: View {
}
Spacer()
TextField("Search karaoke music",text:$search).textFieldStyle(.roundedBorder).frame(maxWidth:260)
if ["Library","Singer Songs","Playlists"].contains(source) {
Button("Song Settings") { djSongSettings = true }
.sheet(isPresented:$djSongSettings) {
VStack(spacing:0) {
HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24)
Divider()
detailPanel.frame(height:150).padding(16)
Divider()
HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20)
}.frame(width:920).background(Color(nsColor:.windowBackgroundColor))
}
}
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered)
}
}.padding(.horizontal,22).padding(.vertical,18)
.sheet(isPresented:$djSongSettings) {
VStack(spacing:0) {
HStack { VStack(alignment:.leading,spacing:4) { Text("Song Settings").font(.title2.bold());Text(selectedSong?.title ?? "Select a karaoke track to adjust its settings").font(.callout).foregroundStyle(.secondary) };Spacer() }.padding(24)
Divider()
detailPanel.frame(height:150).padding(16)
Divider()
HStack { Text("Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { djSongSettings = false }.keyboardShortcut(.defaultAction) }.padding(20)
}.frame(width:920).background(Color(nsColor:.windowBackgroundColor))
}
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
@@ -1058,12 +1055,12 @@ struct LibraryView: View {
}.tableStyle(.inset(alternatesRowBackgrounds:true))
.contextMenu(forSelectionType:Int64.self) { ids in
if ids.count > 1 {
Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
Button("Reinterpret Names…") { beginReinterpretation(ids) }
Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true }
}
else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) }
Divider()
Menu("Waveforms") { waveformActions(ids) }.disabled(model.generatingWaveforms || model.scanningSource != nil)
} primaryAction: { ids in if let id = ids.first { selected = id;tableSelection = [id];playSelected() } }
.overlay {
if visibleSongs.isEmpty {
@@ -1078,9 +1075,8 @@ struct LibraryView: View {
}
@ViewBuilder private func songActions(_ song: Song) -> some View {
Button("Play") { selected = song.id;playSelected() }
Button("Song Settings…") { selected = song.id;tableSelection = [song.id];djSongSettings = true }
Button("Song Info…") { editingSong = song }
Button("Generate Waveform") { model.generateWaveforms([song.id]) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Regenerate Waveform") { model.generateWaveforms([song.id],force:true) }.disabled(model.generatingWaveforms || model.scanningSource != nil || song.audio.hasPrefix("showscreen:"))
Button("Reinterpret Name…") { beginReinterpretation([song.id]) }
Button("Find Metadata Online…") { metadataLookup = song }
Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true }
@@ -1233,13 +1229,17 @@ struct LibraryView: View {
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
}
private var playlistPane: some View { PlaylistWorkspace(model:model,query:$search) }
@ViewBuilder private var waveformActions: some View {
@ViewBuilder private var waveformActions: some View { waveformActions(tableSelection) }
@ViewBuilder private func waveformActions(_ ids: Set<Int64>) -> some View {
Button("Generate Waveform for Loaded Track") { if let id = model.player.currentSongID { model.generateWaveforms([id]) } }.disabled(model.player.currentSongID == nil)
Button("Generate Waveform for Selected Track") { if let selected { model.generateWaveforms([selected]) } }.disabled(tableSelection.count > 1 || selected == nil)
Button("Generate Waveforms for \(tableSelection.count) Selected Tracks") { model.generateWaveforms(tableSelection) }.disabled(tableSelection.isEmpty)
if ids.count == 1 {
Button("Generate Waveform for Selected Track") { model.generateWaveforms(ids) }
} else {
Button("Generate Waveforms for \(ids.count) Selected Tracks") { model.generateWaveforms(ids) }.disabled(ids.isEmpty)
}
Button("Generate Waveforms for All Library Tracks") { model.generateWaveforms() }.disabled(model.songs.isEmpty)
Divider()
Button("Regenerate Selected Waveforms") { model.generateWaveforms(tableSelection,force:true) }.disabled(tableSelection.isEmpty)
Button("Regenerate Selected Waveforms") { model.generateWaveforms(ids,force:true) }.disabled(ids.isEmpty)
Button("Regenerate All Library Waveforms") { model.generateWaveforms(force:true) }.disabled(model.songs.isEmpty)
}
private var footer: some View {
@@ -1248,7 +1248,6 @@ struct LibraryView: View {
Text("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary)
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil)
if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) }
Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary)
RepeatButton(player:model.player).buttonStyle(.borderless)
+2 -1
View File
@@ -151,7 +151,8 @@ import CoreAudio
try? data.write(to:mixURL,options:.atomic)
onGlobalMixChanged?(globalMix)
}
@Published private(set) var waveform = [Float]()
@Published private(set) var waveform = [Float]() { didSet { waveformRevision = UUID() } }
private(set) var waveformRevision = UUID()
@Published private(set) var waveformBands = [WaveformBands]()
@Published var inputGain: Double = 0 { didSet { tone.globalGain = Float(min(12,max(-12,inputGain))) } }
@Published var lowGain: Double = 0 { didSet { updateTone() } }