Files
KMix_Pro/KJamsClone/DJWorkstation.swift

2413 lines
166 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AVFoundation
import CoreAudio
import CoreMIDI
import UniformTypeIdentifiers
import Combine
/// Equal-power crossfade, independent of the karaoke master and singer preferences.
enum DJMix {
static func weights(_ position: Double,curve: String = "Equal power") -> (Double, Double) {
let x = position.isFinite ? min(1,max(0,position)) : 0.5
if curve == "Linear" { return (1-x,x) }
return (cos(x * .pi / 2),sin(x * .pi / 2))
}
static func syncRate(bpm: Double, otherBPM: Double, otherRate: Double) -> Double? {
guard bpm.isFinite,otherBPM.isFinite,otherRate.isFinite,bpm >= 40,bpm <= 240,otherBPM >= 40,otherBPM <= 240 else { return nil }
let result = otherBPM * otherRate / bpm
return (0.5...2).contains(result) ? result : nil
}
}
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
var protectPlayingDeck = true
var tempoRange: Double = 16
var invertTempo = true
var crossfaderCurve = "Equal power"
var headroomDB: Double = 0
var spectralWaveforms = true
var waveformSeconds: Double = 8
var showTimeGrid = true
var platterMarker = true
var dimInactiveDeck = false
var libraryFont: Double = 11
var libraryRowHeight: Double = 28
var autoPlayHotCue = false
func tempo(at position: Double) -> Double { let x = min(1,max(0,position));return 1+((invertTempo ? 1-x : x)*2-1)*tempoRange/100 }
func position(for tempo: Double) -> Double { let x = min(1,max(0,(tempo-1)/(2*tempoRange/100)+0.5));return invertTempo ? 1-x : x }
var sanitized: Self {
var p = self;p.tempoRange = [8.0,16,50].contains(tempoRange) ? tempoRange : 16
p.headroomDB = [0.0,3,6,9].contains(headroomDB) ? headroomDB : 0
p.waveformSeconds = [4.0,8,16,32].contains(waveformSeconds) ? waveformSeconds : 8
p.libraryFont = libraryFont.isFinite ? min(16,max(10,libraryFont)) : 11
p.libraryRowHeight = libraryRowHeight.isFinite ? min(44,max(24,libraryRowHeight)) : 28
if !["Equal power","Linear"].contains(crossfaderCurve) { p.crossfaderCurve = "Equal power" };return p
}
}
@MainActor final class DJChannel: ObservableObject {
let player: Player
let letter: String
@Published private(set) var song: Song?
@Published var bpm: Double = 120
@Published var bpmConfirmed = false
@Published var bpmEstimated = false
@Published var analysingBPM = false
@Published var artwork: NSImage?
private var infoTask: Task<Void,Never>?
private var analysisTask: Task<Double?,Never>?
@Published var gain: Double = 0 { didSet { player.inputGain = gain } }
@Published var level: Double = 0.8 { didSet { updateLevel() } }
@Published var loopBeats = 8 {
didSet {
guard oldValue != loopBeats,loopBeats > 0,player.loopEnabled,let start = player.loopIn,let end = player.loopOut else { return }
let length = bpmConfirmed ? Double(loopBeats)*60/bpm : (end-start)*Double(loopBeats)/Double(max(1,oldValue))
player.defineLoop(start:start,end:min(player.duration,start+length))
}
}
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() { 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
infoTask = Task { @MainActor [weak self] in
let image = await DJTrackInfo.artwork(song)
guard !Task.isCancelled,let self,self.song?.id == song.id else { return }
self.artwork = image
let task = Task.detached(priority:.utility) { try? DJBPM.analyse(URL(fileURLWithPath:song.audio),cancelled:{Task.isCancelled}) };self.analysisTask = task
let bpm = await task.value
guard !Task.isCancelled,self.song?.id == song.id else { return }
self.analysingBPM = false
if !self.bpmConfirmed,let bpm { self.bpm = bpm;self.bpmEstimated = true;self.bpmConfirmed = true }
}
}
deinit { infoTask?.cancel();analysisTask?.cancel() }
func tap(at time: Double = ProcessInfo.processInfo.systemUptime) {
if let last = taps.last,time-last > 2 { taps = [] }
taps.append(time);taps = Array(taps.suffix(8))
guard taps.count >= 3,let first = taps.first,let last = taps.last,last > first else { return }
let value = 60 * Double(taps.count-1)/(last-first)
if (40...240).contains(value) { bpm = (value*10).rounded()/10;bpmConfirmed = true;bpmEstimated = false }
}
func beatLoop() {
if player.loopEnabled { player.clearLoop();return }
guard bpmConfirmed,player.canJog else { return }
let start = player.time,end = min(player.duration,start+Double(loopBeats)*60/bpm)
player.defineLoop(start:start,end:end)
}
}
struct DJAudioDevice: Identifiable, Equatable {
var id: AudioDeviceID
var uid: String
var name: String
var outputChannels = 2
var inputChannels = 0
var stereoPairs: [Int] { Array(stride(from:0,to:max(0,outputChannels-1),by:2)) }
static func channelCount(_ id: AudioDeviceID,scope: AudioObjectPropertyScope = kAudioDevicePropertyScopeOutput) -> Int {
var address = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreamConfiguration,mScope:scope,mElement:kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id,&address,0,nil,&size) == noErr,size >= MemoryLayout<AudioBufferList>.size else { return 0 }
let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout<AudioBufferList>.alignment);defer { memory.deallocate() }
guard AudioObjectGetPropertyData(id,&address,0,nil,&size,memory) == noErr else { return 0 }
return UnsafeMutableAudioBufferListPointer(memory.assumingMemoryBound(to:AudioBufferList.self)).reduce(0) { $0+Int($1.mNumberChannels) }
}
static func outputs() -> [Self] { allDevices().filter { $0.outputChannels > 0 } }
static func allDevices() -> [Self] {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size) == noErr else { return [] }
var ids = [AudioDeviceID](repeating:0,count:Int(size)/MemoryLayout<AudioDeviceID>.size)
let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) }
guard result == noErr else { return [] }
return ids.compactMap { id in
let channels = channelCount(id),inputs = channelCount(id,scope:kAudioDevicePropertyScopeInput)
guard channels > 0 || inputs > 0 else { return nil }
func string(_ selector: AudioObjectPropertySelector) -> String? {
var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var text: Unmanaged<CFString>?;var bytes = UInt32(MemoryLayout<CFString?>.size)
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
return text?.takeRetainedValue() as String?
}
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channels,inputChannels:inputs)
}
}
static func defaultOutput() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDefaultOutputDevice,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var id = AudioDeviceID(0),size = UInt32(MemoryLayout<AudioDeviceID>.size)
return AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,&id) == noErr && id != 0 ? id : nil
}
}
@MainActor final class DJSystem: ObservableObject {
@Published private(set) var storageInSettings = true
@Published var cleanupPlan: DJCleanupPlan?
@Published private(set) var storageAction: String?
private func beginMaintenance(_ title: String,work: @escaping @Sendable (@escaping () -> Bool,@escaping (ImportProgress) -> Void) throws -> ImportProgress,completion: (() -> Void)? = nil) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
storageAction = title;movingStorage = true;storageProgress = .init(current:title)
storageTask = Task.detached(priority:.utility) { [weak self] in
var latest = ImportProgress(current:title)
do { latest = try work({Task.isCancelled},{ value in latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } }) }
catch is CancellationError { latest.cancelled = true;latest.current = "Cancelled; incomplete work stopped" }
catch { latest.failure = error.localizedDescription;latest.current = "Library maintenance needs attention" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
guard let self else { return };self.movingStorage = false;self.storageTask = nil
if !result.cancelled,result.failure == nil { completion?() }
self.storageProgress = result
}
}
}
func removeLibraryTracks(_ ids: Set<Int64>,fromSettings: Bool = false) {
guard !ids.isEmpty else { return }
guard ![a.player,b.player,preview].contains(where:{$0.playing && ($0.currentSongID.map{ids.contains($0)} ?? false)}) else { error = "Stop the affected deck before removing its track.";return }
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
automaticWaveforms.cancel()
storageInSettings = fromSettings
automix.stop("Library update")
for deck in [a,b] where deck.song.map({ids.contains($0.id)}) == true { deck.unload() }
if preview.currentSongID.map({ids.contains($0)}) == true { stopPreview() }
let database = root.appendingPathComponent("Library.sqlite"),removingAll = !songs.isEmpty && Set(songs.map(\.id)).isSubset(of:ids)
beginMaintenance("Removing Library Tracks",work:{ cancelled,_ in
try DJLibraryMaintenance.removeTracks(ids,database:database,cancelled:cancelled)
return ImportProgress(completed:ids.count,total:ids.count,current:"Removed \(ids.count) library tracks. Media files were kept.",finished:true,logLines:["[REMOVED] \(ids.count) library entries. Original and managed media files were kept."])
},completion:{ [weak self] in
guard let self else { return }
if removingAll { self.automix.clear();self.favourites.removeAll() }
else { self.automix.removeSongs(ids);self.favourites.subtract(ids) }
self.perform { try self.library?.setSetting("DJ.Favourites.v1",self.favourites.sorted().map(String.init).joined(separator:","));try self.refresh() }
})
}
func findUnusedFiles() {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback before cleaning DJ storage.";return }
let database = root.appendingPathComponent("Library.sqlite"),folder = root
storageInSettings = true;storageAction = "Finding Unused Files";movingStorage = true;storageProgress = .init(current:"Finding unused DJ files…")
storageTask = Task.detached(priority:.utility) { [weak self] in
do {
let plan = try DJLibraryMaintenance.cleanupPlan(database:database,root:folder,cancelled:{Task.isCancelled}) { value in Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value } }
await MainActor.run { [weak self] in self?.movingStorage = false;self?.storageTask = nil;self?.cleanupPlan = plan;self?.storageProgress = nil }
} catch {
let cancelled = error is CancellationError,message = error.localizedDescription
await MainActor.run { [weak self] in self?.movingStorage = false;self?.storageTask = nil;self?.storageProgress = .init(current:cancelled ? "Scan cancelled" : "Cleanup needs attention",finished:true,cancelled:cancelled,failure:cancelled ? nil : message) }
}
}
}
func cleanUnusedFiles(_ plan: DJCleanupPlan) {
let database = root.appendingPathComponent("Library.sqlite"),folder = root
beginMaintenance("Cleaning Unused Files",work:{ cancelled,progress in try DJLibraryMaintenance.clean(plan,database:database,root:folder,cancelled:cancelled,progress:progress) })
cleanupPlan = nil
}
@Published var copyImportedMedia = false { didSet { perform { try library?.setSetting("DJ.CopyImportedMedia.v1",copyImportedMedia ? "1" : "0") } } }
private var referenceAccess = [String:URL]()
private var referenceLocations = [MediaLocation]()
private func ensureReferenceAccess(_ path: String) {
for location in referenceLocations where path == location.path || path.hasPrefix(location.path+"/") {
guard referenceAccess[location.path] == nil,let bookmark = location.bookmark else { continue }
var stale = false
if let url = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale),url.startAccessingSecurityScopedResource() { referenceAccess[location.path] = url }
}
}
@Published private(set) var mediaDirectory: URL
@Published private(set) var waveformDirectory: URL
@Published private(set) var movingStorage = false
@Published var storageProgress: ImportProgress?
private var storageTask: Task<Void,Never>?
private var storageAccess: URL?
private func loadStorage() throws {
let location = try library.flatMap{try DJStorageMove.location(in:$0)}
var folder = location.map{URL(fileURLWithPath:$0.path)} ?? root
if let bookmark = location?.bookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { folder = resolved } }
if storageAccess != folder {
storageAccess?.stopAccessingSecurityScopedResource();storageAccess = folder.startAccessingSecurityScopedResource() ? folder : nil
}
mediaDirectory = folder.appendingPathComponent("Media");waveformDirectory = folder.appendingPathComponent("Waveforms")
for player in [a.player,b.player,preview] { player.useWaveformCache(at:waveformDirectory) }
}
func chooseStorage() {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy else { error = "Finish the current library job first.";return }
guard !a.player.playing,!b.player.playing,!preview.playing else { error = "Stop both decks and headphone preview before moving DJ files.";return }
let panel = NSOpenPanel();panel.title = "Move DJ Media and Waveforms";panel.prompt = "Move Here"
panel.canChooseFiles = false;panel.canChooseDirectories = true;panel.canCreateDirectories = true
panel.message = "Choose a folder on your external drive. DJ media and saved waveforms move into Media and Waveforms folders here. The database and playlists stay on this Mac."
panel.begin { [weak self] response in guard response == .OK,let url = panel.url else { return };Task { @MainActor in self?.moveStorage(to:url) } }
}
func moveStorage(to destination: URL) {
guard !movingStorage,!importing,!generatingWaveforms,!namesBusy,!a.player.playing,!b.player.playing,!preview.playing else { error = "Stop playback and finish the current library job before moving DJ files.";return }
let loadedA = a.song?.id,loadedB = b.song?.id,database = root.appendingPathComponent("Library.sqlite"),localRoot = root
automaticWaveforms.cancel()
a.unload();b.unload();stopPreview();storageInSettings = true;storageAction = nil;movingStorage = true;storageProgress = .init(current:"Preparing DJ storage move…")
storageTask = Task.detached(priority:.utility) { [weak self] in
let access = destination.startAccessingSecurityScopedResource();defer { if access { destination.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
do { latest = try DJStorageMove.run(database:database,defaultRoot:localRoot,destination:destination,cancelled:{Task.isCancelled}) { value in
latest = value;Task { @MainActor [weak self] in guard let self,self.movingStorage,self.storageTask?.isCancelled != true else { return };self.storageProgress = value }
} } catch is CancellationError { latest.cancelled = true;latest.current = "Move cancelled; verified files kept" }
catch { latest.failure = error.localizedDescription;latest.current = "Move needs attention" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
guard let self else { return };self.movingStorage = false;self.storageTask = nil
self.perform { try self.loadStorage();try self.refresh() }
for (id,deck) in [(loadedA,self.a),(loadedB,self.b)] { if let id,let song = self.songs.first(where:{$0.id == id}) { self.perform { try deck.load(song) } } }
self.storageProgress = result
}
}
}
func cancelStorageMove() { storageTask?.cancel();storageProgress?.current = "Cancelling… Originals and verified copies will be kept." }
@Published var namesBusy = false
private var graphicSaveTask: Task<Void,Never>?
@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 var automaticWaveformProgress: WaveformJobProgress?
let automaticWaveforms = PostImportWaveformQueue()
private func queueImportedWaveforms(_ ids: Set<Int64>) {
automaticWaveforms.enqueue(songs.filter{ids.contains($0.id)},locations:referenceLocations,cache:waveformDirectory,update:{ [weak self] in self?.automaticWaveformProgress = $0 },completed:{ [weak self] tracks in
guard let self else { return }
for deck in [self.a,self.b] where tracks.contains(where:{$0.id == deck.song?.id}) { deck.player.refreshCachedWaveform() }
})
}
@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,!movingStorage 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 = waveformDirectory,locations = referenceLocations
generatingWaveforms = true;waveformJob = .init(total:tracks.count)
waveformTask = Task.detached(priority:.utility) { [weak self] in
let result = await WaveformBatch.runConcurrent(songs:tracks,locations:locations,cache:cache,force:force,progress: { update in
Task { @MainActor [weak self] in self?.waveformJob = update }
})
await MainActor.run { [weak self] in
guard let self else { return }
self.waveformJob = result;self.generatingWaveforms = false;self.waveformTask = nil
for deck in [self.a,self.b] where tracks.contains(where:{$0.id == deck.song?.id}) { deck.player.refreshCachedWaveform() }
}
}
}
func cancelWaveforms() { waveformTask?.cancel();waveformJob?.current = "Cancelling…" }
func dismissWaveforms() { if !generatingWaveforms { waveformJob = nil } }
@Published private(set) var favourites = Set<Int64>()
let a: DJChannel
let b: DJChannel
let preview = Player(persistsMix:false)
let midi = DJMIDI()
lazy var automix = DJAutomix(system:self)
@Published var crossfader: Double = 0.5 { didSet { applyMix() } }
@Published var master: Double = 0.8 { didSet { applyMix() } }
@Published private(set) var syncActive = false
private(set) var syncLeader = "A"
private var syncOriginal: (Double,Double)?
private var syncObservers = Set<AnyCancellable>()
private var updatingSync = false
@Published var pendingReplacement: DJReplacement?
@Published var cueEditor: String?
@Published var cueDraft: Double = 0
@Published var cueEditing = false
@Published var loopEditor: String?
@Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } }
@Published private(set) var previewDeck: String?
@Published var devices = [DJAudioDevice]()
@Published private(set) var detectedDevices = [DJAudioDevice]()
@Published var masterUID = ""
@Published var cueUID = ""
@Published private(set) var routing = DJAudioRouting()
private var audioDeviceMonitor: DJAudioDeviceMonitor?
@Published var settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } }
@Published var status = "Load music into A or B. Karaoke keeps its own mixer and output."
@Published var error: String?
let root: URL
private(set) var library: Library?
let dragSession = UUID().uuidString
@Published var songs = [Song]()
@Published var playlists = [PersonPlace]()
@Published var importProgress: ImportProgress?
@Published var lastImportReport: ImportProgress?
@Published var importLogVisible = false
@Published private(set) var importing = false
private var importTask: Task<Void,Never>?
private var importGeneration = UUID()
private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]()
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
mediaDirectory = root.appendingPathComponent("Media");waveformDirectory = root.appendingPathComponent("Waveforms")
a = DJChannel("A",waveformCache:root.appendingPathComponent("Waveforms"));b = DJChannel("B",waveformCache:root.appendingPathComponent("Waveforms"))
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
Task { @MainActor [weak self] in self?.maintainSync() }
}.store(in:&syncObservers)
Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).sink { [weak self] _ in
Task { @MainActor [weak self] in self?.updateControllerFeedback() }
}.store(in:&syncObservers)
Publishers.CombineLatest4(a.player.$hotCues,b.player.$hotCues,a.player.$loopEnabled,b.player.$loopEnabled).sink { [weak self] _ in
Task { @MainActor [weak self] in self?.updateControllerFeedback() }
}.store(in:&syncObservers)
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));copyImportedMedia = try library?.setting("DJ.CopyImportedMedia.v1") == "1";try loadStorage();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) }
applyMix()
}
audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() }
}
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [];referenceLocations = try library?.mediaLocations() ?? [] }
func toggleFavourite(_ song: Int64) {
var updated = favourites
if !updated.insert(song).inserted { updated.remove(song) }
perform {
try library?.setSetting("DJ.Favourites.v1",updated.sorted().map(String.init).joined(separator:","))
favourites = updated
}
}
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name);try refresh() } }
func rename(_ table: String,id: Int64,name: String) { perform { try library?.rename(table,id:id,name:name);try refresh() } }
func delete(_ table: String,id: Int64) { perform { try library?.deleteEntity(table,id:id);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 importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
guard !importing,!generatingWaveforms,!movingStorage,!urls.isEmpty else { return }
importing = true;importProgress = .init();importLogVisible = true
let importID = UUID();importGeneration = importID
let database = root.appendingPathComponent("Library.sqlite"),media = mediaDirectory,beforeIDs = Set(songs.map(\.id)),copyMedia = copyImportedMedia,replacedSong = loadInto.flatMap{channel($0).song?.id}
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
var importedIDs = [Int64]()
do {
let db = try Library(url:database)
if !copyMedia {
for url in urls {
if Task.isCancelled { throw CancellationError() }
let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil))
_ = try db.saveMediaLocation(name:url.lastPathComponent,url:url,bookmark:bookmark,external:true)
}
}
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false,continueOnUnreadableAudio:true).run(urls,library:db,copyMedia:copyMedia,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true,self?.importGeneration == importID { self?.importProgress = value } }
}
} catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest,loadedIDs = importedIDs
await MainActor.run { [weak self] in
guard let self,self.importGeneration == importID else { return };self.importing = false;self.importTask = nil;self.importProgress = result
self.lastImportReport = result
self.perform { try self.refresh() }
if let failure = result.failure { if loadInto != nil,!self.importLogVisible { self.error = failure } }
else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) }
if !result.cancelled { self.queueImportedWaveforms(Set(loadedIDs).union(self.songs.filter{!beforeIDs.contains($0.id)}.map(\.id))) }
self.continueFileLoads()
}
}
}
func cancelImport() { queuedFileLoads = [];importTask?.cancel();if importing { importProgress?.current = "Cancelling…" } }
func continueFileLoads() {
guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return }
let next = queuedFileLoads.removeFirst()
requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song)
}
func cancelReplacement() { pendingReplacement = nil;continueFileLoads() }
deinit { importTask?.cancel();waveformTask?.cancel();storageTask?.cancel();storageAccess?.stopAccessingSecurityScopedResource();for url in referenceAccess.values { url.stopAccessingSecurityScopedResource() } }
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 channel(_ letter: String) -> DJChannel { letter == "A" ? a : b }
private(set) var nextLibraryDeck = "A"
func loadFromLibraryDoubleClick(_ song: Song) {
guard pendingReplacement == nil else { return }
let target = nextLibraryDeck
nextLibraryDeck = target == "A" ? "B" : "A"
if channel(target).player.playing {
pendingReplacement = .init(deck:target,song:song,urls:[])
} else { load(song,into:target) }
}
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
guard !movingStorage else { error = "Wait for the DJ storage move to finish.";return }
perform {
ensureReferenceAccess(song.audio)
let deck = channel(letter)
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
if !automated { automix.manualTakeover() }
if syncActive { disableSync() }
if previewDeck == letter { stopPreview() }
let route = routing.route(letter)
// Configure HAL before load starts the engine, rather than restarting it immediately afterwards.
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel);try deck.load(song) } catch { deck.unload();throw error }
if settings.resetEQOnLoad { deck.player.resetTone() }
if settings.autoPlayOnLoad && !automated { deck.player.toggle() }
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
}
}
func refreshDevices() {
let all = DJAudioDevice.allDevices(),outputs = all.filter{$0.outputChannels > 0}
if detectedDevices != all { detectedDevices = all }
if devices != outputs { devices = outputs }
midi.refreshSources()
}
private func device(_ uid: String) throws -> AudioDeviceID {
if uid.isEmpty,let id = DJAudioDevice.defaultOutput() { return id }
guard let output = devices.first(where:{$0.uid == uid}) else { throw LibraryError.message("Selected audio output is disconnected. Refresh and choose an available output.") }
return output.id
}
func routeMaster(_ uid: String) {
var next = routing;next.main.uid = uid;next.main.firstChannel = 0;applyRouting(next)
}
func applyRouting(_ next: DJAudioRouting) {
let previous = routing
perform {
try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
automix.manualTakeover()
let previewActive = previewDeck != nil
do {
for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) }
if previewActive,!next.cue.uid.isEmpty { try preview.routeOutput(try device(next.cue.uid),firstChannel:next.cue.firstChannel) }
try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic)
routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix()
if next.cue.uid.isEmpty { stopPreview() }
status = "Audio routing applied · \(next.mode) mixer"
} catch {
for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } }
if previewActive,!previous.cue.uid.isEmpty,let output = try? device(previous.cue.uid) { try? preview.routeOutput(output,firstChannel:previous.cue.firstChannel) }
throw error
}
}
}
private func validateCue() throws {
var config = routing;config.cue.uid = cueUID
try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
}
func previewLibrary(_ song: Song) {
ensureReferenceAccess(song.audio)
perform {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") }
let output = try device(cueUID)
try validateCue()
stopPreview();var audio = song;audio.cdg = nil
try preview.load(audio,startPlaying:false);try preview.routeOutput(output,firstChannel:routing.cue.firstChannel);preview.toggle();previewDeck = "Library"
}
}
func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() }
func startPreview(_ deck: DJChannel) {
if previewDeck == deck.letter { stopPreview();return }
perform {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a separate headphone output in Audio Routing first.") }
guard let song = deck.song else { throw LibraryError.message("Load a track first.") }
let output = try device(cueUID)
try validateCue()
stopPreview();var audio = song;audio.cdg = nil
try preview.load(audio,startPlaying:false)
try preview.routeOutput(output,firstChannel:routing.cue.firstChannel)
preview.tempo = deck.player.tempo;preview.lowGain = deck.player.lowGain;preview.midGain = deck.player.midGain;preview.highGain = deck.player.highGain;preview.filter = deck.player.filter
preview.seek(deck.player.time);preview.toggle();previewDeck = deck.letter;updateControllerFeedback()
status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position"
}
}
func sync(_ deck: DJChannel) {
if syncActive { disableSync();return }
guard a.player.canJog,b.player.canJog else { status = "Load both decks before enabling Sync.";return }
guard a.bpmConfirmed,b.bpmConfirmed else { status = "Waiting for BPM analysis. Set or tap BPM on both decks if a tempo cannot be detected.";return }
let follower = deck.letter == "A" ? b : a
let target = deck.bpm*deck.player.tempo,rate = target/follower.bpm
guard rate.isFinite,(0.5...2).contains(rate) else { status = "These tracks require a tempo outside the supported 50–200% range.";return }
syncOriginal = (a.player.tempo,b.player.tempo)
syncLeader = deck.letter;syncActive = true;updatingSync = true;follower.player.tempo = rate;updatingSync = false
status = String(format:"SYNC ON · Both decks %.1f BPM · %@ is master",target,deck.letter)
}
func disableSync() {
guard syncActive else { return };syncActive = false
if let original = syncOriginal { a.player.tempo = original.0;b.player.tempo = original.1 }
syncOriginal = nil;status = "SYNC OFF · Both decks restored to their pre-sync tempos"
}
private func maintainSync() {
guard syncActive,!updatingSync,a.bpmConfirmed,b.bpmConfirmed else { return }
let leader = channel(syncLeader),follower = syncLeader == "A" ? b : a
let rate = leader.bpm*leader.player.tempo/follower.bpm
guard rate.isFinite,(0.5...2).contains(rate) else { disableSync();return }
if abs(follower.player.tempo-rate) > 0.00001 { updatingSync = true;follower.player.tempo = rate;updatingSync = false }
}
private func setDeckTempo(_ rate: Double,deck: DJChannel) {
if syncActive,deck.letter != syncLeader {
let leader = channel(syncLeader)
leader.player.tempo = deck.bpm*rate/leader.bpm
maintainSync()
} else { deck.player.tempo = rate }
}
func toggleLoop(_ deck: DJChannel) {
guard deck.player.canJog else { status = "Load a track first.";return }
guard deck.player.loopEnabled || deck.player.time < deck.player.duration-0.1 else { status = "Move the playhead before the end of the track to set a loop.";return }
guard deck.player.loopEnabled || deck.bpmConfirmed else { status = "Waiting for BPM analysis. Enter or tap the track BPM, or define a loop with IN and OUT.";return }
deck.beatLoop();status = deck.player.loopEnabled ? "Deck \(deck.letter) · \(deck.loopBeats)-beat loop active" : "Deck \(deck.letter) · Loop off"
}
func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true }
func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false }
private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck,aCues:a.player.hotCues.map{$0 != nil},bCues:b.player.hotCues.map{$0 != nil},aLoop:a.player.loopEnabled,bLoop:b.player.loopEnabled) }
func hotCue(_ index: Int,deck: DJChannel) {
let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil
deck.player.hotCue(index)
if existing,settings.autoPlayHotCue,!deck.player.playing { deck.player.toggle() }
}
func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) {
if name == "Controller feedback" { updateControllerFeedback();return }
if name == "Crossfader" { automix.manualTakeover();crossfader = min(1,max(0,value));return }
if name == "Master" { master = min(1,max(0,value));return }
if name == "Cue volume" { cueLevel = min(1,max(0,value));return }
let parts = name.split(separator:" ",maxSplits:1).map(String.init)
guard parts.count == 2 else { return };let d = channel(parts[0]),p = d.player
if ["Play","Stop","Cue","Cue button","Cue play","Sync","Load","Tempo","Tempo XML","Jog","Jog ticks"].contains(parts[1]) { automix.manualTakeover() }
switch parts[1] {
case "Play": p.toggle()
case "Stop": p.stopDeck()
case "Cue": p.returnToCue()
case "Set cue": p.setCue()
case "Cue button": if p.playing { p.returnToCue() } else { p.setCue() }
case "Cue play": p.returnToCue();p.toggle()
case "Loop off": p.clearLoop()
case "Loop in": p.markLoopIn()
case "Loop out": p.markLoopOut()
case "Loop double","Loop half":
let factor = parts[1] == "Loop double" ? 2.0 : 0.5
d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor)))
case "Tempo XML":
let rate = 1+(value*2-1)*settings.tempoRange/100
setDeckTempo(rate,deck:d)
case "Loop 1","Loop 2","Loop 4","Loop 8","Loop 16","Loop 32":
let beats = Int(parts[1].split(separator:" ").last!) ?? 8
if p.loopEnabled,d.loopBeats != beats { d.loopBeats = beats;status = "Deck \(d.letter) · \(beats)-beat loop active" }
else { d.loopBeats = beats;toggleLoop(d) }
case "Clear cue 1","Clear cue 2","Clear cue 3","Clear cue 4": p.hotCue((Int(parts[1].suffix(1)) ?? 1)-1,clear:true)
case "Sync": sync(d)
case "Loop": toggleLoop(d)
case "Headphones": startPreview(d)
case "Load": if let selected { load(selected,into:d.letter) }
case "Gain": d.gain = value*24-12
case "High": p.highGain = value*24-12
case "Mid": p.midGain = value*24-12
case "Low": p.lowGain = value*24-12
case "Filter": p.filter = value*2-1
case "Level": d.level = value
case "Tempo": setDeckTempo(settings.tempo(at:value),deck:d)
case "Jog ticks": p.jog(value*2/242)
case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2)
case "Hot cue 1": hotCue(0,deck:d)
case "Hot cue 2": hotCue(1,deck:d)
case "Hot cue 3": hotCue(2,deck:d)
case "Hot cue 4": hotCue(3,deck:d)
default: break
}
}
}
// Streaming MIDI 1.0 decoder: preserves running status across packets; ignores clock and SysEx.
struct DJMIDIMessage: Equatable { var status: UInt8;var data1: UInt8;var data2: UInt8 }
struct DJMIDIDecoder {
private var status: UInt8 = 0
private var data = [UInt8]()
mutating func feed(_ bytes: [UInt8]) -> [DJMIDIMessage] {
var result = [DJMIDIMessage]()
for byte in bytes {
if byte >= 0xF8 { continue }
if byte & 0x80 != 0 { status = byte < 0xF0 ? byte : 0;data = [];continue }
guard status != 0 else { continue };data.append(byte)
let count = [0xC0,0xD0].contains(status & 0xF0) ? 1 : 2
if data.count == count {
if [0x80,0x90,0xB0,0xE0].contains(status & 0xF0) { result.append(.init(status:status,data1:data[0],data2:data.count > 1 ? data[1] : 0)) }
data = []
}
}
return result
}
}
struct DJMIDIMapping: Codable, Identifiable, Equatable {
var source: Int32;var status: UInt8;var number: UInt8;var action: String;var mode: String
var id: String { "\(source):\(status):\(number)" }
static func value(_ byte: UInt8,mode: String) -> Double {
if mode == "Relative signed" { return byte < 64 ? Double(byte) : Double(Int(byte)-128) }
if mode == "Relative offset" { return Double(Int(byte)-64) }
return Double(byte)/127
}
}
@MainActor final class DJMIDI: ObservableObject {
struct Source: Identifiable { var id: Int32;var endpoint: MIDIEndpointRef;var name: String }
@Published var sources = [Source]()
@Published private(set) var sourceID: Int32 = 0
@Published var mappings = [DJMIDIMapping]()
@Published private(set) var profiles = [String:String]()
@Published var xmlCatalog = DJXMLProfile.bundled
@Published private(set) var xmlAssignments = [String:DJXMLProfile]()
@Published var autoDetect = true { didSet { defaults.set(autoDetect,forKey:"DJ.MIDIAutoDetect.v1");if autoDetect { refreshSources() } } }
private var pickupValues = [String:Double](),pickedUp = Set<String>()
var currentValue: ((String) -> Double?)?
var activeXML: DJXMLProfile? { profiles[String(sourceID)]?.hasPrefix("XML:") == true ? xmlAssignments[String(sourceID)] : nil }
private var mixtrack = MixtrackGoProtocol()
private var outputPort = MIDIPortRef(0),outputDestination = MIDIEndpointRef(0)
private var closing = false
private var terminationObserver: AnyCancellable?
private var initializationTask: Task<Void,Never>?
private var lastFeedback = [UInt8]()
@Published var learning: String?
@Published var learnMode = "Absolute"
@Published var status = "Choose a MIDI source; learn each control. No assumed factory mapping."
var onAction: ((String,Double,Bool) -> Void)?
private var client = MIDIClientRef(0),port = MIDIPortRef(0),connected = MIDIEndpointRef(0)
private var decoder = DJMIDIDecoder()
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard,connect: Bool = true,catalog: [DJXMLProfile] = DJXMLProfile.bundled) {
self.defaults = defaults;self.xmlCatalog = catalog
autoDetect = defaults.object(forKey:"DJ.MIDIAutoDetect.v1") as? Bool ?? true
if let data = defaults.data(forKey:"DJ.XMLAssignments.v1"),let saved = try? JSONDecoder().decode([String:DJXMLProfile].self,from:data) { xmlAssignments = saved }
if let data = defaults.data(forKey:"DJ.XMLCatalog.v1"),let saved = try? JSONDecoder().decode([DJXMLProfile].self,from:data) { mergeXML(saved) }
for (source,profile) in xmlAssignments { if let updated = xmlCatalog.first(where:{$0.id == profile.id}) { xmlAssignments[source] = updated } }
profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:]
if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved }
guard connect else { return }
terminationObserver = NotificationCenter.default.publisher(for:NSApplication.willTerminateNotification).sink { [weak self] _ in self?.shutdown() }
let result = MIDIClientCreateWithBlock("KJMix DJ" as CFString,&client) { [weak self] _ in Task { @MainActor in self?.refreshSources() } }
guard result == noErr else { status = "MIDI unavailable (\(result))";return }
MIDIOutputPortCreate(client,"DJ feedback" as CFString,&outputPort)
let input = MIDIInputPortCreateWithBlock(client,"DJ controls" as CFString,&port) { [weak self] list,_ in
var bytes = [UInt8]()
// Walk the original variable-length packet list, never a copied 256-byte tuple.
var packet = UnsafeRawPointer(list).advanced(by:MemoryLayout<MIDIPacketList>.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self)
for _ in 0..<list.pointee.numPackets {
let data = UnsafeRawPointer(packet).advanced(by:MemoryLayout<MIDIPacket>.offset(of: \.data)!)
bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length)))
packet = UnsafePointer(MIDIPacketNext(packet))
}
let received = bytes
Task { @MainActor in self?.receive(received) }
}
if input != noErr { status = "MIDI input unavailable (\(input))" }
refreshSources()
}
func refreshSources() {
sources = (0..<MIDIGetNumberOfSources()).map { index in
let endpoint = MIDIGetSource(index);var uid: Int32 = 0;var name: Unmanaged<CFString>?
MIDIObjectGetIntegerProperty(endpoint,kMIDIPropertyUniqueID,&uid)
MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&name)
return Source(id:uid,endpoint:endpoint,name:name?.takeRetainedValue() as String? ?? "MIDI source")
}
if connected != 0,!sources.contains(where:{$0.endpoint == connected}) {
disconnect();status = "Controller disconnected · waiting to reconnect"
}
if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber,
sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) }
if connected == 0,autoDetect,defaults.object(forKey:"DJ.MIDISource.v1") == nil {
let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil }
if matches.count == 1,let match = matches.first { select(match.id) }
else if matches.count > 1 { status = "Multiple supported controllers detected · choose a MIDI input" }
}
}
private func disconnect() {
initializationTask?.cancel();initializationTask = nil;lastFeedback = []
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = []
}
func select(_ id: Int32) {
guard !closing else { return }
disconnect()
if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");autoDetect = false;return }
guard let source = sources.first(where:{$0.id == id}) else { return }
let result = MIDIPortConnectSource(port,source.endpoint,nil)
if result == noErr { connected = source.endpoint;sourceID = id;defaults.set(Int(id),forKey:"DJ.MIDISource.v1");status = "Connected: \(source.name)";mixtrack = .init();selectFeedbackOutput(source.name)
if autoDetect,profiles[String(id)] == nil {
if let profile = DJXMLProfile.match(source.name,in:xmlCatalog) { applyXML(profile) }
else if DJXMLProfile.normalized(source.name) == "numark mixtrack go" { installMixtrackGo() }
} else if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) }
else if profiles[String(id)] == "Mixtrack Go" { configureMixtrackMappings();onAction?("Controller feedback",0,false) }
}
else { status = "Could not connect MIDI source (\(result))" }
}
func receive(_ bytes: [UInt8]) {
guard !closing else { return }
for message in decoder.feed(bytes) {
let type = message.status & 0xF0
if let profile = activeXML,profile.adapter == nil,learning == nil {
if type == 0x80 || (type == 0x90 && message.data2 == 0) { continue }
for binding in profile.bindings where binding.matches(message) {
let value = binding.value(message)
if binding.pickup,binding.mode == "Absolute",let target = currentValue?(binding.action),!pickedUp.contains(binding.key) {
let previous = pickupValues[binding.key];pickupValues[binding.key] = value
guard abs(value-target) < 0.025 || previous.map({ ($0-target)*(value-target) <= 0 }) == true else { continue }
pickedUp.insert(binding.key)
}
if binding.mode != "Absolute",value == 0 { continue }
onAction?(binding.action,value,binding.mode != "Absolute")
}
continue
}
guard type == 0xB0 || (type == 0x90 && message.data2 > 0) else { continue }
if let action = learning {
let mapping = DJMIDIMapping(source:sourceID,status:message.status,number:message.data1,action:action,mode:action.hasSuffix(" Jog") ? learnMode : "Absolute")
mappings.removeAll{$0.id == mapping.id || ($0.source == sourceID && $0.action == action)};mappings.append(mapping);save();learning = nil;status = "Learned \(action) · channel \((message.status & 0x0F)+1), control \(message.data1)"
continue
}
if mixtrackEnabled,let events = mixtrack.consume(message) {
for event in events {
if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) }
else { onAction?(event.action,event.value,event.relative) }
}
onAction?("Controller feedback",0,false)
continue
}
guard let map = mappings.first(where:{$0.source == sourceID && $0.status == message.status && $0.number == message.data1}) else { continue }
let continuous = ["Gain","High","Mid","Low","Filter","Level","Tempo","Jog"].contains(map.action.split(separator:" ",maxSplits:1).last.map(String.init) ?? "") || ["Crossfader","Master","Cue volume","Browse"].contains(map.action)
if !continuous && message.data2 == 0 { continue }
onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute")
}
}
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" }
private func configureMixtrackMappings() {
let preset = MixtrackGoProtocol.mappings(source:sourceID)
let ids = Set(preset.map(\.id))
mappings.removeAll { ids.contains($0.id) };mappings += preset;mixtrack = .init();save();initializeMixtrack()
}
func installMixtrackGo() {
profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1")
configureMixtrackMappings()
status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pads and LED feedback"
onAction?("Controller feedback",0,false)
}
func shutdown() { resetController();closing = true }
func resetController() {
initializationTask?.cancel();initializationTask = nil;lastFeedback = [];learning = nil
if mixtrackEnabled { sendBytes(MixtrackGoProtocol.shutdownBytes) }
else if let outputs = activeXML?.outputs { for output in outputs { sendBytes([output.status,output.number,0]) } }
mixtrack = .init()
}
func useCustomProfile() { initializationTask?.cancel();lastFeedback = [];profiles[String(sourceID)] = "Custom";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" }
func mergeXML(_ imported: [DJXMLProfile]) {
let ids = Set(imported.map(\.id));xmlCatalog.removeAll { ids.contains($0.id) };xmlCatalog += imported
xmlCatalog.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
func addXMLProfiles(_ imported: [DJXMLProfile]) {
mergeXML(imported)
if let data = try? JSONEncoder().encode(xmlCatalog) { defaults.set(data,forKey:"DJ.XMLCatalog.v1") }
status = "Imported \(imported.count) XML profiles · select one to review and apply"
if autoDetect { refreshSources() }
}
func applyXML(_ profile: DJXMLProfile) {
guard !profile.bindings.isEmpty else { status = "This profile has no supported standalone MIDI controls · see compatibility report";return }
xmlAssignments[String(sourceID)] = profile;profiles[String(sourceID)] = "XML:"+profile.id
defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1")
if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") }
pickupValues = [:];pickedUp = [];learning = nil
if profile.adapter == "Mixtrack Go" {
configureMixtrackMappings()
}
onAction?("Controller feedback",0,false)
status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report"
}
private func initializeMixtrack() {
initializationTask?.cancel();lastFeedback = []
sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7])
sendBytes([0xF0,0x00,0x20,0x7F,0x03,0x01,0xF7])
let source = sourceID,destination = outputDestination
// The controller can clear LEDs as its startup demo stops. Refresh after that transition.
initializationTask = Task { [weak self] in
do { try await Task.sleep(nanoseconds:2_000_000_000) } catch { return }
guard let self,self.sourceID == source,self.outputDestination == destination,self.mixtrackEnabled else { return }
self.onAction?("Controller feedback",0,false);self.sendBytes(self.lastFeedback)
}
}
private func selectFeedbackOutput(_ name: String) {
outputDestination = 0
for i in 0..<MIDIGetNumberOfDestinations() {
let endpoint = MIDIGetDestination(i);var result: Unmanaged<CFString>?
MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&result)
let candidate = (result?.takeRetainedValue() as String? ?? "").lowercased()
if candidate == name.lowercased() { outputDestination = endpoint;break }
}
}
func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) {
guard !closing else { return }
if !mixtrackEnabled {
guard let outputs = activeXML?.outputs else { return }
for output in outputs {
let parts = output.action.split(separator:" ",maxSplits:1).map(String.init)
guard parts.count == 2 else { continue }
let isA = parts[0] == "A",loaded = isA ? aLoaded : bLoaded,cues = isA ? aCues : bCues
let active: Bool
switch parts[1] {
case "Play": active = isA ? aPlaying : bPlaying
case "Cue button": active = loaded
case "Sync": active = sync
case "Headphones": active = cue == parts[0]
case "Loop": active = isA ? aLoop : bLoop
default:
let index = (Int(parts[1].split(separator:" ").last ?? "0") ?? 0)-1
active = cues.indices.contains(index) && cues[index]
}
sendBytes(output.packet(active:active))
}
return
}
lastFeedback = mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop)
sendBytes(lastFeedback)
}
private func sendBytes(_ bytes: [UInt8]) {
guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return }
var list = MIDIPacketList()
withUnsafeMutablePointer(to:&list) { pointer in
let packet = MIDIPacketListInit(pointer)
_ = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,$0.count,$0.baseAddress!) }
MIDISend(outputPort,outputDestination,pointer)
}
}
func remove(_ id: String) { mappings.removeAll{$0.id == id};save() }
private func save() { if let data = try? JSONEncoder().encode(mappings) { defaults.set(data,forKey:"DJ.MIDIMappings.v1") } }
deinit { initializationTask?.cancel();if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } }
}
private enum DJStyle {
static let background = Color(red:0.045,green:0.055,blue:0.075)
static let panel = Color(red:0.085,green:0.10,blue:0.13)
static let a = Color(red:0.18,green:0.85,blue:0.76)
static let b = Color(red:0.69,green:0.55,blue:1.0)
static func color(_ deck: String) -> Color { deck == "A" ? a : b }
static func stamp(_ value: Double) -> String {
guard value.isFinite else { return "0:00" };let seconds = Int(max(0,value));return String(format:"%d:%02d",seconds/60,seconds%60)
}
}
/// 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();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() } }
func attach() {
guard window != nil,scroll == nil else { return }
func find(_ view: NSView) -> NSScrollView? {
if let scroll = view as? NSScrollView { return scroll }
for child in view.subviews { if let found = find(child) { return found } }
return nil
}
var parent = superview
while let view = parent {
if let found = find(view) { scroll = found;break }
parent = view.superview
}
guard let scroll else { return }
lastY = scroll.contentView.bounds.origin.y
observation = NotificationCenter.default.addObserver(forName:NSScrollView.didLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in
guard let self,let scroll = self.scroll,let document = scroll.documentView else { return }
let y = scroll.contentView.bounds.origin.y
let forward = y > self.lastY+0.1
self.lastY = y
let bottom = document.bounds.height-scroll.contentView.bounds.height
guard !self.advanced,forward,bottom > 1,y >= bottom-2 else { return }
self.advanced = true
DispatchQueue.main.async { [weak self] in self?.next?() }
}
endObservation = NotificationCenter.default.addObserver(forName:NSScrollView.didEndLiveScrollNotification,object:scroll,queue:.main) { [weak self] _ in
self?.advanced = false;self?.lastY = self?.scroll?.contentView.bounds.origin.y ?? 0
}
}
deinit {
if let observation { NotificationCenter.default.removeObserver(observation) }
if let endObservation { NotificationCenter.default.removeObserver(endObservation) }
}
}
}
private struct DJQueueButton: View {
@ObservedObject var automix: DJAutomix
let open: () -> Void
var body: some View {
Button(action:open) { Label("DJ Queue (\(automix.entries.count))",systemImage:"text.line.first.and.arrowtriangle.forward") }
.tint(automix.enabled ? .green : .accentColor).help("Open the DJ queue, reorder tracks and control Automix")
}
}
private struct DJLibrarySnapshot<Content: View>: View, Equatable {
let revision: UUID
let selection: Set<Int64>
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() }
}
enum DJCollection: String,CaseIterable,Identifiable {
case all = "All Music",favourites = "Favourites",rated = "Top Rated",unrated = "Unrated",missingArtist = "Needs Metadata"
var id: String { rawValue }
var icon: String { switch self { case .all:return "music.note.list";case .favourites:return "star.fill";case .rated:return "rosette";case .unrated:return "star";case .missingArtist:return "tag" } }
func includes(_ song: Song,favourites: Set<Int64>) -> Bool {
switch self {
case .all:return true
case .favourites:return favourites.contains(song.id)
case .rated:return song.rating >= 4
case .unrated:return song.rating == 0
case .missingArtist:return song.artist.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty
}
}
}
enum DJLibrarySort: String,CaseIterable,Identifiable {
case title = "Title",artist = "Artist",album = "Album",duration = "Duration",rating = "Rating",source = "Playlist / Import Order"
var id: String { rawValue }
}
enum DJLibraryBrowser {
static func rows(_ songs: [Song],collection: DJCollection,favourites: Set<Int64>,playlist: [Int64]? = nil,query: String = "",sort: DJLibrarySort = .title,ascending: Bool = true) -> [Song] {
let source: [Song]
if let playlist { let lookup = Dictionary(uniqueKeysWithValues:songs.map{($0.id,$0)});source = playlist.compactMap{lookup[$0]} }
else { source = songs }
let filtered = source.filter { song in
guard !song.audio.hasPrefix("showscreen:"),SongSearch.matches(song,query:query) else { return false }
return collection.includes(song,favourites:favourites)
}
guard sort != .source else {
let ordered = playlist == nil ? filtered.sorted{$0.id < $1.id} : filtered
return ascending ? ordered : Array(ordered.reversed())
}
return filtered.sorted { lhs,rhs in
let comparison: ComparisonResult
switch sort {
case .title:comparison = lhs.title.localizedStandardCompare(rhs.title)
case .artist:comparison = lhs.artist.localizedStandardCompare(rhs.artist)
case .album:comparison = lhs.album.localizedStandardCompare(rhs.album)
case .duration:comparison = lhs.duration == rhs.duration ? .orderedSame : lhs.duration < rhs.duration ? .orderedAscending : .orderedDescending
case .rating:comparison = lhs.rating == rhs.rating ? .orderedSame : lhs.rating < rhs.rating ? .orderedAscending : .orderedDescending
case .source:comparison = .orderedSame
}
if comparison == .orderedSame { return ascending ? lhs.id < rhs.id : lhs.id > rhs.id }
return ascending ? comparison == .orderedAscending : comparison == .orderedDescending
}
}
}
struct DJConsole: View {
@ObservedObject var system: DJSystem
@State private var removingIDs = Set<Int64>()
@State private var confirmTrackRemoval = false
@State private var reinterpreting = false
@State private var reinterpretIDs = Set<Int64>()
@State private var editingTrack: Song?
@State private var graphicEQVisible = false
@State private var waveformDetails = false
@State private var collection = DJCollection.all
@State private var librarySort = DJLibrarySort.title
@State private var ascending = true
@State private var collectionCounts = [DJCollection:Int]()
@AppStorage("DJStudio.librarySidebar") private var sidebarVisible = true
@AppStorage("DJStudio.performanceHeight") private var performanceHeight = 454.0
@AppStorage("DJStudio.sidebarWidth") private var sidebarWidth = 184.0
@State private var performanceDragStart: Double?
@State private var sidebarDragStart: Double?
@State private var queueVisible = false
@State private var query = ""
@State private var playlist: Int64?
@State private var playlistIDs = [Int64]()
@State private var selection = Set<Int64>()
@State private var selected: Int64?
@State private var playlistEditor = false
@State private var playlistName = ""
@State private var renaming = false
@State private var page = 0
@State private var libraryRevision = UUID()
@State private var filteredTracks = [Song]()
private var songs: [Song] { filteredTracks }
private func updateFilteredTracks() {
libraryRevision = UUID()
filteredTracks = DJLibraryBrowser.rows(system.songs,collection:collection,favourites:system.favourites,playlist:playlist == nil ? nil : playlistIDs,query:query,sort:librarySort,ascending:ascending)
collectionCounts = Dictionary(uniqueKeysWithValues:DJCollection.allCases.map { scope in
(scope,system.songs.lazy.filter{!$0.audio.hasPrefix("showscreen:") && scope.includes($0,favourites:system.favourites)}.count)
})
selection.formIntersection(Set(filteredTracks.map(\.id)))
page = min(page,max(0,(filteredTracks.count+49)/50-1))
if let selected,!selection.contains(selected) { self.selected = nil }
}
private var selectedTracks: [Song] { songs.filter { selection.contains($0.id) } }
private func selectTrack(_ id: Int64) {
let modifiers = NSEvent.modifierFlags
if modifiers.contains(.shift),let anchor = selected,let first = songs.firstIndex(where:{$0.id == anchor}),let last = songs.firstIndex(where:{$0.id == id}) {
let range = Set(songs[min(first,last)...max(first,last)].map(\.id))
if modifiers.contains(.command) { selection.formUnion(range) } else { selection = range }
} else if modifiers.contains(.command) {
if !selection.insert(id).inserted { selection.remove(id) }
selected = id
} else { selection = [id];selected = id }
}
private func selectAllTracks() { selection = Set(songs.map(\.id));selected = songs.first?.id }
private func contextTracks(_ song: Song) -> [Song] { selection.contains(song.id) ? selectedTracks : [song] }
private var selectedSong: Song? { system.songs.first{$0.id == selected} }
private var pages: Int { max(1,(songs.count+49)/50) }
private var visible: [Song] { Array(songs.dropFirst(min(page,pages-1)*50).prefix(50)) }
var body: some View {
VStack(spacing:10) {
header
GeometryReader { geometry in
VStack(spacing:0) {
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).frame(height:performanceSize(geometry.size.height))
RoundedRectangle(cornerRadius:2).fill(Color.white.opacity(0.16)).frame(width:44,height:3).frame(maxWidth:.infinity).frame(height:10).contentShape(Rectangle())
.help("Drag to resize the performance area; double-click to reset")
.onTapGesture(count:2) { performanceHeight = 454 }
.gesture(DragGesture(coordinateSpace:.global).onChanged { value in
if performanceDragStart == nil { performanceDragStart = performanceSize(geometry.size.height) }
performanceHeight = min(max(418,(performanceDragStart ?? 454)+value.translation.height),min(580,max(418,geometry.size.height-220)))
}.onEnded { _ in performanceDragStart = nil })
.accessibilityLabel("Resize DJ performance area")
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)
}
}
}.padding(14).frame(minWidth:900,maxWidth:.infinity,minHeight:706,maxHeight:.infinity,alignment:.top)
.background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark)
.alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in
Button("Cancel",role:.cancel) { system.cancelReplacement() }
Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) }
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
.sheet(isPresented:$system.importLogVisible) {
if let progress = system.importing ? system.importProgress : system.lastImportReport {
ImportProgressPanel(progress:progress,studio:"DJ Studio",cancel:{ system.cancelImport() },close:{ system.importLogVisible = false },background:{ system.importLogVisible = false },waveformStatus:system.automaticWaveformProgress.map { progress in BackgroundWaveformStatus(progress:progress,cancel:{system.automaticWaveforms.cancel()},dismiss:{system.automaticWaveformProgress = nil}) })
.interactiveDismissDisabled(system.importing)
}
}
.sheet(isPresented:Binding(get:{system.storageProgress != nil && !system.storageInSettings},set:{if !$0,!system.movingStorage { system.storageProgress = nil }})) {
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Library",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},activity:system.storageAction).interactiveDismissDisabled(system.movingStorage) }
}
.confirmationDialog("Remove selected tracks from DJ Studio?",isPresented:$confirmTrackRemoval,titleVisibility:.visible) {
Button("Remove \(removingIDs.count) Tracks",role:.destructive) { system.removeLibraryTracks(removingIDs) }
} message: { Text("Library entries and their queue/playlist links are removed. Media files and referenced originals are kept.") }
.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() }})) {
if let progress = system.waveformJob {
VStack(alignment:.leading,spacing:20) {
Label("DJ Studio Waveforms",systemImage:"waveform").font(.title2.bold()).foregroundStyle(DJStyle.a)
Text(system.generatingWaveforms ? "Preparing saved waveforms in the background." : "Completed waveforms are saved and ready to use.").font(.callout).foregroundStyle(.secondary)
WaveformProgressCard(progress:progress,running:system.generatingWaveforms,studio:"DJ",cancel:{system.cancelWaveforms()},details:{waveformDetails.toggle()},dismiss:{system.dismissWaveforms()})
if waveformDetails || !progress.messages.isEmpty {
Text("Analysis Log").font(.headline)
ScrollView { Text(progress.messages.isEmpty ? "No issues reported." : progress.messages.joined(separator:"\n\n")).font(.system(size:11,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(height:180)
}
HStack {
Text("Cancelling keeps completed waveforms.").font(.caption).foregroundStyle(.secondary)
Spacer()
if !system.generatingWaveforms { Button("Done") { system.dismissWaveforms() }.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) }
}
}.padding(26).frame(width:660).background(DJStyle.background).preferredColorScheme(.dark)
.interactiveDismissDisabled(system.generatingWaveforms)
}
}
.sheet(isPresented:$queueVisible) {
VStack(spacing:16) {
DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(maxWidth:.infinity,maxHeight:.infinity)
HStack { Spacer();Button("Done") { queueVisible = false }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:620,height:560).background(DJStyle.background).preferredColorScheme(.dark)
}
.sheet(isPresented:$playlistEditor) { playlistSheet }
.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() }
.onStudioChange(of:selected) { _ in connectMIDI() }
.onStudioChange(of:selection) { ids in if selected == nil || !ids.contains(selected!) { selected = songs.first(where:{ids.contains($0.id)})?.id } }
.onStudioChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil;selection = [] }
.onStudioChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onStudioChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onStudioChange(of:query) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:collection) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:librarySort) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:ascending) { _ in updateFilteredTracks();page = 0 }
.onStudioChange(of:sidebarVisible) { _ in libraryRevision = UUID() }
.onStudioChange(of:sidebarWidth) { _ in libraryRevision = UUID() }
.onStudioChange(of:system.favourites) { _ in updateFilteredTracks() }
}
private func performanceSize(_ available: Double) -> Double { max(0,min(max(418,performanceHeight),min(580,max(0,available-220)))) }
private var header: some View {
HStack(spacing:14) {
ZStack { RoundedRectangle(cornerRadius:10).fill(DJStyle.a.opacity(0.14));Image(systemName:"waveform.path").font(.system(size:20,weight:.semibold)).foregroundStyle(DJStyle.a) }.frame(width:40,height:40)
VStack(alignment:.leading,spacing:3) { Text("DJ STUDIO").font(.system(size:15,weight:.bold,design:.rounded)).tracking(2);Text("PERFORMANCE WORKSPACE").font(.system(size:8,weight:.medium)).tracking(1.8).foregroundStyle(.secondary) }
Spacer()
HStack(spacing:8) { Text("MASTER").font(.system(size:9,weight:.semibold)).foregroundStyle(.secondary);DJHardwareFader(value:$system.master,label:"DJ master level").frame(width:95,height:28).help("Adjust the overall DJ output level for both decks") }
Button { sidebarVisible.toggle() } label: { Image(systemName:"sidebar.left") }.help("Show or hide collections").buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a))
Button { system.importLogVisible = true } label: { Image(systemName:"doc.text") }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).disabled(system.importProgress == nil).help("Open the import log and background waveform progress")
Button { graphicEQVisible = true } label: { Label("Equaliser",systemImage:"slider.vertical.3") }.buttonStyle(LibraryActionButtonStyle(tint:system.graphicEQ.enabled ? DJStyle.a : .gray)).help("Open the ten-band graphic equaliser and sound presets for both decks")
DJQueueButton(automix:system.automix,open:{queueVisible = true})
if system.previewDeck != nil { Button("Stop Preview") { system.stopPreview() }.tint(.green).help("Stop independent headphone preview") }
}.controlSize(.small).padding(.horizontal,2).padding(.bottom,2)
}
private var libraryTitle: String { playlist.flatMap { id in system.playlists.first{$0.id == id}?.name } ?? collection.rawValue }
private func chooseCollection(_ value: DJCollection) { playlist = nil;collection = value;librarySort = .title;ascending = true;refreshPlaylist();page = 0 }
private var collectionSidebar: some View {
ScrollView {
VStack(alignment:.leading,spacing:0) {
Text("COLLECTION").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary).padding(.horizontal,16).padding(.top,18).padding(.bottom,10)
ForEach(DJCollection.allCases) { scope in
collectionRow(scope.rawValue,icon:scope.icon,count:collectionCounts[scope] ?? 0,active:playlist == nil && collection == scope) { chooseCollection(scope) }
}
HStack { Text("PLAYLISTS").font(.system(size:9,weight:.bold)).tracking(1.5).foregroundStyle(.secondary);Spacer();Button { renaming = false;playlistName = "";playlistEditor = true } label: { Image(systemName:"plus") }.buttonStyle(.plain).help("Create playlist") }.padding(.horizontal,16).padding(.top,24).padding(.bottom,8)
VStack(spacing:2) {
ForEach(system.playlists) { list in
collectionRow(list.name,icon:"music.note",count:nil,active:playlist == list.id) { collection = .all;playlist = list.id;librarySort = .source;ascending = true;refreshPlaylist();page = 0 }
.contextMenu {
Button("Rename Playlist…") { playlist = list.id;renaming = true;playlistName = list.name;playlistEditor = true }
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:list.id) }
}
}
}
if system.playlists.isEmpty { Text("Build your next set with playlists.").font(.caption).foregroundStyle(.secondary).padding(.horizontal,16).padding(.bottom,18) }
Spacer(minLength:0)
}.padding(.bottom,12)
}.frame(minWidth:156,idealWidth:176,maxWidth:210,maxHeight:.infinity).background(DJStyle.background)
}
private func collectionRow(_ name: String,icon: String,count: Int?,active: Bool,action: @escaping () -> Void) -> some View {
Button(action:action) {
HStack(spacing:9) { Image(systemName:icon).frame(width:16).foregroundStyle(active ? DJStyle.a : .secondary);Text(name).lineLimit(1);Spacer(minLength:2);if let count { Text(count.formatted()).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) } }.font(.system(size:11,weight:active ? .semibold : .regular)).padding(.horizontal,12).padding(.vertical,9).background(active ? DJStyle.a.opacity(0.12) : .clear,in:RoundedRectangle(cornerRadius:7))
}.buttonStyle(.plain).padding(.horizontal,6).accessibilityValue(active ? "Selected" : "").help("Show \(name)")
}
private var browser: some View {
GeometryReader { geometry in
HStack(spacing:0) {
if sidebarVisible {
collectionSidebar.frame(width:min(max(156,sidebarWidth),210))
Rectangle().fill(Color.white.opacity(0.07)).frame(width:6).contentShape(Rectangle())
.help("Drag to resize collections; double-click to reset")
.onTapGesture(count:2) { sidebarWidth = 184 }
.gesture(DragGesture(coordinateSpace:.global).onChanged { value in
if sidebarDragStart == nil { sidebarDragStart = sidebarWidth }
sidebarWidth = min(210,max(156,(sidebarDragStart ?? 184)+value.translation.width))
}.onEnded { _ in sidebarDragStart = nil })
.accessibilityLabel("Resize DJ collection sidebar")
}
VStack(spacing:0) {
HStack(spacing:12) {
VStack(alignment:.leading,spacing:3) { Text(libraryTitle).font(.system(size:17,weight:.semibold));Text("\(songs.count.formatted()) tracks").font(.caption).foregroundStyle(.secondary) }
Spacer(minLength:8)
StudioSearchField(placeholder:"Search title, artist or album",text:$query).frame(minWidth:160,idealWidth:250,maxWidth:340).help("Filter this collection or playlist by title, artist, album or other track metadata")
Button { importMedia() } label: { Label("Import",systemImage:"square.and.arrow.down") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a,prominent:true)).help("Import audio files or folders, including subfolders; already imported tracks are skipped")
}.padding(14)
HStack(spacing:10) {
Picker("Sort tracks",selection:$librarySort) { ForEach(DJLibrarySort.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width:155).help("Sort the visible tracks; playlist/import order preserves the original sequence")
Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.buttonStyle(.plain).help("Reverse sort order")
LibrarySelectionBadge(count:selection.count)
Spacer(minLength:4)
Button { system.automix.enqueue(selectedTracks) } label: { Label("Queue Selected",systemImage:"text.badge.plus") }.buttonStyle(LibraryActionButtonStyle(tint:.green)).disabled(selectedTracks.isEmpty).help("Add every selected track to the DJ queue")
HStack(spacing:8) {
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0).help("Previous library page")
Text("\(min(page,pages-1)+1) / \(pages)").font(.system(size:10).monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1).help("Next library page")
}.buttonStyle(.plain)
}.controlSize(.small).padding(.horizontal,14).padding(.bottom,10)
Divider().overlay(Color.white.opacity(0.05))
ScrollViewReader { reader in
Table(visible,selection:$selection) {
TableColumn("Fav") { 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)").help(system.favourites.contains(song.id) ? "Remove this track from Favourites" : "Add this track to Favourites")
}.width(38)
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:150,ideal:260,max:1200)
TableColumn("Artist") { song in Text(song.artist).lineLimit(1).help(song.artist) }.width(min:90,ideal:150,max:800)
TableColumn("Album") { song in Text(song.album).lineLimit(1).foregroundStyle(.secondary).help(song.album) }.width(min:80,ideal:120,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)
}.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(page:page) { if page < pages-1 { page += 1 } })
.onStudioChange(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) } }
}
}
if let song = selectedSong {
HStack(spacing:10) {
Image(systemName:"music.note").foregroundStyle(DJStyle.a)
VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:11,weight:.semibold)).lineLimit(1);Text(song.artist.isEmpty ? "Artist not set" : song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
Button("Info") { editingTrack = song }.buttonStyle(LibraryActionButtonStyle(tint:.gray)).help("Edit the selected track’s title, artist, album and metadata")
Button("Load A") { system.load(song,into:"A") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.a)).help("Load the selected track into deck A; a playing deck is protected")
Button("Load B") { system.load(song,into:"B") }.buttonStyle(LibraryActionButtonStyle(tint:DJStyle.b)).help("Load the selected track into deck B; a playing deck is protected")
}.padding(10).background(DJStyle.background)
}
}.frame(minWidth:530,maxWidth:.infinity,maxHeight:.infinity).background(DJStyle.panel)
}
}.clipShape(RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).stroke(.white.opacity(0.07)))
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Remove Selected Tracks…",role:.destructive) { removingIDs = Set(contextTracks(song).map(\.id));confirmTrackRemoval = true }
Divider()
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()
Button(system.favourites.contains(song.id) ? "Remove from Favourites" : "Add to Favourites") { system.toggleFavourite(song.id) }
Button("Select All Songs") { selectAllTracks() }.keyboardShortcut("a",modifiers:.command)
Button("Add \(contextTracks(song).count) Selected Tracks to DJ Queue") { system.automix.enqueue(contextTracks(song)) }
Menu("Waveforms") {
Button("Generate Waveforms for \(contextTracks(song).count) Selected Tracks") { system.generateWaveforms(Set(contextTracks(song).map(\.id))) }
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 Selected Waveforms") { system.generateWaveforms(Set(contextTracks(song).map(\.id)),force:true) }
Button("Regenerate This Waveform") { system.generateWaveforms([song.id],force:true) }
Button("Regenerate All DJ Library Waveforms") { system.generateWaveforms(force:true) }
}.disabled(system.generatingWaveforms || system.importing)
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist {
Button("Move Earlier") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:-1) };refreshPlaylist() }
Button("Move Later") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:1) };refreshPlaylist() }
Button("Remove from Playlist") { system.perform { try system.library?.removeFromPlaylist(playlist,song:song.id) };refreshPlaylist() }
}
}
private var playlistSheet: some View {
VStack(alignment:.leading,spacing:16) {
Text(renaming ? "Rename Playlist" : "New Playlist").font(.title2)
TextField("Playlist name",text:$playlistName).textFieldStyle(.roundedBorder)
HStack { Button("Cancel") { playlistEditor = false };Spacer();Button("Save") {
let name = playlistName.trimmingCharacters(in:.whitespacesAndNewlines)
if renaming,let playlist { system.rename("playlists",id:playlist,name:name) } else { system.createPlaylist(name) }
playlistEditor = false
}.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty).keyboardShortcut(.defaultAction) }
}.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)} ?? [];updateFilteredTracks() }
private func connectMIDI() {
system.midi.currentValue = { [weak system] action in
guard let system else { return nil }
if action == "Crossfader" { return system.crossfader }
if action == "Master" { return system.master }
if action == "Cue volume" { return system.cueLevel }
let parts = action.split(separator:" ",maxSplits:1).map(String.init)
guard parts.count == 2 else { return nil };let d = system.channel(parts[0]),p = d.player
switch parts[1] {
case "Tempo XML": return (p.tempo-1)/(2*system.settings.tempoRange/100)+0.5
case "High": return (p.highGain+12)/24
case "Mid": return (p.midGain+12)/24
case "Low": return (p.lowGain+12)/24
case "Filter": return (p.filter+1)/2
case "Gain": return (d.gain+12)/24
case "Level": return d.level
default: return nil
}
}
system.midi.onAction = { [weak system] name,value,relative in
guard let system else { return }
if name == "Browse" {
guard value != 0 else { return }
let rows = songs;guard !rows.isEmpty else { return }
let current = rows.firstIndex(where:{$0.id == selected}) ?? (value > 0 ? -1 : rows.count)
let index = min(rows.count-1,max(0,current+(value >= 0 ? 1 : -1)))
selected = rows[index].id;selection = [rows[index].id];page = index/50
} else if name == "Preview selected",let selectedSong { system.previewLibrary(selectedSong) }
else { system.action(name,value:value,relative:relative,selected:selectedSong) }
}
}
private func importMedia() {
let panel = NSOpenPanel();panel.title = "Import DJ Music · Files or Folders";panel.canChooseFiles = true;panel.canChooseDirectories = true;panel.allowsMultipleSelection = true
if panel.runModal() == .OK { system.importFiles(panel.urls) }
}
}
private struct DJDeckSurfaces {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
private var color: Color { DJStyle.color(deck.letter) }
var tempoStrip: some View {
VStack(spacing:3) {
Button("SYNC") { system.sync(deck) }.foregroundStyle(system.syncActive ? color : Color.secondary).font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(system.syncActive ? color : nil,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Match the other deck to this deck’s BPM; click again to restore both original tempos").accessibilityIdentifier("dj.\(deck.letter).sync")
Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).buttonStyle(.plain).foregroundStyle(.secondary).help("Reset tempo")
DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:44,height:74)
HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) }.help("Decrease tempo by 0.1 percentage points");Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) }.help("Increase tempo by 0.1 percentage points") }.buttonStyle(.plain).font(.system(size:15,weight:.medium))
}.frame(width:62).disabled(!player.canJog)
}
var tonePanel: some View {
HStack(alignment:.top,spacing:10) {
VStack(spacing:8) {
DJKnob(label:"HIGH",value:$player.highGain,range:-12...12,reset:0,color:color)
DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color)
DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color)
}
VStack(spacing:8) {
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
HStack(spacing:3) {
VStack(spacing:5) {
ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:5,height:5) }
}.accessibilityLabel("Deck output meter").help("Live output peak level for this deck")
DJHardwareFader(value:$deck.level,label:"Deck \(deck.letter) level",vertical:true).frame(width:44,height:70)
}
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)
}
}
struct DJKnob: NSViewRepresentable {
let label: String
@Binding var value: Double
let range: ClosedRange<Double>
let reset: Double
let color: Color
var format: (Double) -> String = {String(format:"%+.0f dB",$0)}
func makeCoordinator() -> Coordinator { Coordinator(value:$value) }
func makeNSView(context: Context) -> DJRotaryControl {
let view = DJRotaryControl();view.changed = { [weak coordinator = context.coordinator] value in coordinator?.value.wrappedValue = value };return view
}
func updateNSView(_ view: DJRotaryControl,context: Context) {
context.coordinator.value = $value
view.label = label;view.value = value;view.range = range;view.defaultValue = reset;view.colour = NSColor(color);view.isEnabled = context.environment.isEnabled
view.setAccessibilityElement(true);view.setAccessibilityRole(.slider);view.setAccessibilityLabel(label);view.setAccessibilityValue(format(value))
view.setAccessibilityCustomActions([NSAccessibilityCustomAction(name:"Reset to default",handler:{[weak view] in view?.resetToDefault();return true})])
view.toolTip = "\(label): \(format(value)). Drag vertically; double-click to reset."
view.needsDisplay = true
}
func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJRotaryControl,context: Context) -> CGSize? { CGSize(width:36,height:40) }
final class Coordinator {
var value: Binding<Double>
init(value: Binding<Double>) { self.value = value }
}
}
/// Native click counting keeps a double-click reset independent of drag recognition.
final class DJRotaryControl: NSControl {
var label = "",value: Double = 0,defaultValue: Double = 0
var range: ClosedRange<Double> = -12...12
var colour = NSColor.systemTeal
var changed: ((Double) -> Void)?
private var dragStart: (point: NSPoint,value: Double)?
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
func resetToDefault() { guard isEnabled else { return };dragStart = nil;set(defaultValue) }
private func set(_ next: Double) { value = min(range.upperBound,max(range.lowerBound,next));needsDisplay = true;changed?(value) }
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
window?.makeFirstResponder(self)
if event.clickCount >= 2 { resetToDefault();return }
dragStart = (convert(event.locationInWindow,from:nil),value)
}
override func mouseDragged(with event: NSEvent) {
guard isEnabled,let start = dragStart else { return }
let point = convert(event.locationInWindow,from:nil)
set(start.value-Double(point.y-start.point.y)*(range.upperBound-range.lowerBound)/120)
}
override func mouseUp(with event: NSEvent) { dragStart = nil }
override func keyDown(with event: NSEvent) {
if event.keyCode == 126 || event.keyCode == 124 { set(value+(range.upperBound-range.lowerBound)/100) }
else if event.keyCode == 125 || event.keyCode == 123 { set(value-(range.upperBound-range.lowerBound)/100) }
else if event.keyCode == 49 { resetToDefault() }
else { super.keyDown(with:event) }
}
override func accessibilityPerformIncrement() -> Bool { guard isEnabled else { return false };set(value+(range.upperBound-range.lowerBound)/100);return true }
override func accessibilityPerformDecrement() -> Bool { guard isEnabled else { return false };set(value-(range.upperBound-range.lowerBound)/100);return true }
override func draw(_ dirtyRect: NSRect) {
let centre = NSPoint(x:bounds.midX,y:14.5)
let circle = NSBezierPath(ovalIn:NSRect(x:centre.x-14.5,y:0,width:29,height:29))
NSColor.black.withAlphaComponent(0.35).setFill();circle.fill()
NSColor.white.withAlphaComponent(0.12).setStroke();circle.lineWidth = 2;circle.stroke()
let arc = NSBezierPath();arc.appendArc(withCenter:centre,radius:14,startAngle:135,endAngle:405,clockwise:false)
colour.withAlphaComponent(isEnabled ? 0.4 : 0.15).setStroke();arc.lineWidth = 2;arc.stroke()
let fraction = min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound)))
let angle = (-135+fraction*270) * .pi/180
let marker = NSBezierPath();marker.move(to:NSPoint(x:centre.x+sin(angle)*5,y:centre.y-cos(angle)*5));marker.line(to:NSPoint(x:centre.x+sin(angle)*11,y:centre.y-cos(angle)*11));marker.lineWidth = 2;marker.lineCapStyle = .round
colour.withAlphaComponent(isEnabled ? 1 : 0.3).setStroke();marker.stroke()
let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center
(label as NSString).draw(in:NSRect(x:0,y:30,width:bounds.width,height:10),withAttributes:[.font:NSFont.systemFont(ofSize:8,weight:.medium),.foregroundColor:NSColor.secondaryLabelColor,.paragraphStyle:paragraph])
}
}
private struct DJPlatter: NSViewRepresentable {
let player: Player
let letter: String
let color: Color
var markerEnabled = true
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?
@ObservedObject var player: Player
let color: Color
var bpm: Double?
var body: some View {
let displayTime = player.displayTime
GeometryReader { geometry in
Canvas { context,size in
let values = player.waveform
guard !values.isEmpty else { return }
let count = max(1,Int(size.width/2))
var played = Path(),remaining = Path()
for i in 0..<count {
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
let rect = CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)
if Double(i)/Double(count) < displayTime/max(0.001,player.duration) { played.addRect(rect) } else { remaining.addRect(rect) }
}
context.fill(played,with:.color(color.opacity(0.4)))
context.fill(remaining,with:.color(color.opacity(0.9)))
DJWaveMarkers.draw(context:&context,size:size,start:0,span:player.duration,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
let x = displayTime/max(0.001,player.duration)*size.width
context.fill(Path(CGRect(x:x,y:0,width:1.5,height:size.height)),with:.color(.white))
}.background(.black.opacity(0.3),in:RoundedRectangle(cornerRadius:3))
.overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } }
.modifier(DJLoopWaveInteraction(player:player,start:0,span:player.duration,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) }
}
}
private struct DJMappingSheet: View {
@ObservedObject var midi: DJMIDI
var embedded = false
@State private var action = "A Play"
@State private var xmlSelection = ""
@State private var xmlSearch = ""
@State private var report: DJXMLProfile?
private var selectedXML: DJXMLProfile? { midi.xmlCatalog.first { $0.id == xmlSelection } }
private var filteredXML: [DJXMLProfile] { midi.xmlCatalog.filter { xmlSearch.isEmpty || $0.name.localizedCaseInsensitiveContains(xmlSearch) || $0.id == xmlSelection } }
@Environment(\.dismiss) private var dismiss
private let commands = ["Play","Stop","Cue","Set cue","Sync","Loop","Headphones","Load","Gain","High","Mid","Low","Filter","Level","Tempo","Jog","Hot cue 1","Hot cue 2","Hot cue 3","Hot cue 4"]
private var actions: [String] { ["Crossfader","Master"]+["A","B"].flatMap{deck in commands.map{deck+" "+$0}} }
var body: some View {
VStack(alignment:.leading,spacing:14) {
HStack { Text("MIDI Controller Mapping").font(.title2.bold());Spacer();Button("Refresh") { midi.refreshSources() } }
Picker("MIDI input",selection:Binding(get:{midi.sourceID},set:{midi.select($0)})) { Text("Not connected").tag(Int32(0));ForEach(midi.sources) { Text($0.name).tag($0.id) } }
HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green) } }
Toggle("Automatically detect and reconnect supported controllers",isOn:$midi.autoDetect)
VStack(alignment:.leading,spacing:8) {
HStack { Text("XML Controller Profiles").font(.headline);Spacer();Button("Import XML…") { midi.importXMLFiles() } }
TextField("Find a controller",text:$xmlSearch).textFieldStyle(.roundedBorder)
Picker("Profile",selection:$xmlSelection) {
Text("Choose a profile").tag("")
ForEach(filteredXML) { Text($0.name).tag($0.id) }
}
HStack {
Button("Apply XML Profile") { if let selectedXML { midi.applyXML(selectedXML) } }.disabled(midi.sourceID == 0 || selectedXML?.bindings.isEmpty != false)
Button("Compatibility Report") { report = selectedXML }.disabled(selectedXML == nil)
Button("Use Custom Learn") { midi.useCustomProfile() }
}
if let active = midi.activeXML { Text("Active: \(active.name) · \(active.bindings.count) supported controls").font(.caption).foregroundStyle(.green) }
}
Text("Mixtrack Go preset uses the supplied Mixxx protocol mapping, including 14-bit tempo and hot-cue/loop pad modes. Other controllers can use MIDI Learn.").font(.caption).foregroundStyle(.secondary)
HStack {
Picker("Action",selection:$action) { ForEach(actions,id:\.self) { Text($0).tag($0) } }
Picker("Mode",selection:$midi.learnMode) { Text("Absolute").tag("Absolute");Text("Relative signed").tag("Relative signed");Text("Relative offset").tag("Relative offset") }.frame(width:160)
}
HStack {
Button(midi.learning == nil ? "Learn Next Control" : "Cancel Learning") { if midi.learning == nil { midi.learning = action } else { midi.learning = nil } }.disabled(midi.sourceID == 0)
if let learning = midi.learning { Text("Move or press the control for \(learning)…").foregroundStyle(.orange) }
}
Text(midi.status).font(.caption)
List {
if let active = midi.activeXML {
ForEach(Array(active.bindings.enumerated()),id:\.offset) { _,binding in
HStack { Text(binding.action);Spacer();Text("Ch \((binding.status & 15)+1) · \(binding.number) · \(binding.mode)").foregroundStyle(.secondary) }
}
} else {
ForEach(midi.mappings.filter{$0.source == midi.sourceID}) { map in
HStack {
Text(map.action).frame(width:110,alignment:.leading)
Text("\(map.status & 0xF0 == 0xB0 ? "CC" : "Note") \(map.number) · ch \((map.status & 0x0F)+1)").foregroundStyle(.secondary)
Spacer();Text(map.mode).font(.caption).foregroundStyle(.secondary)
Button { midi.remove(map.id) } label: { Image(systemName:"trash") }.buttonStyle(.borderless).accessibilityLabel("Remove mapping for \(map.action)")
}
}
}
}.frame(height:200)
Text("Mappings save per MIDI source ID. Custom learn supports note buttons and 7-bit CCs; the Mixtrack Go preset also handles its 14-bit tempo. Use relative mode only for Jog encoders. Load uses the selected DJ library row. Button releases are ignored. The Mixtrack Go preset sends transport/sync/mode LED feedback when a matching output is found. Stems, sampler, FX modes and vinyl scratching are not implemented.").font(.caption).foregroundStyle(.secondary)
if !embedded { HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } }
}.padding(24).frame(width:660)
.onAppear { if let active = midi.activeXML { xmlSelection = active.id } }
.sheet(item:$report) { profile in
VStack(alignment:.leading,spacing:16) {
Text("Mapping Compatibility").font(.title2.bold())
ScrollView { Text(profile.report).font(.system(size:12)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }
HStack { Spacer();Button("Done") { report = nil }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:700,height:540)
}
}
}
/// Native mouse/keyboard/accessibility behaviour with a rectangular mixer-fader cap.
struct DJHardwareFader: NSViewRepresentable {
@Binding var value: Double
let label: String
var vertical = false
func makeCoordinator() -> Coordinator { Coordinator(value:$value) }
func makeNSView(context: Context) -> NSSlider {
let slider = NSSlider(value:value,minValue:0,maxValue:1,target:context.coordinator,action:#selector(Coordinator.changed(_:)))
slider.cell = DJHardwareFaderCell()
slider.target = context.coordinator;slider.action = #selector(Coordinator.changed(_:))
slider.frame = NSRect(x:0,y:0,width:vertical ? 52 : 240,height:vertical ? 94 : 36)
slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical
slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value
slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false
slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust."
slider.setContentHuggingPriority(.defaultLow,for:.horizontal)
return slider
}
func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label);slider.toolTip = label+". Drag to adjust." }
func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSSlider,context: Context) -> CGSize? { CGSize(width:proposal.width ?? (vertical ? 52 : 240),height:proposal.height ?? (vertical ? 94 : 36)) }
final class Coordinator: NSObject {
var value: Binding<Double>
init(value: Binding<Double>) { self.value = value }
@objc func changed(_ sender: NSSlider) { value.wrappedValue = sender.doubleValue }
}
}
private final class DJHardwareFaderCell: NSSliderCell {
override func drawBar(inside rect: NSRect,flipped: Bool) {
let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0)
NSColor(calibratedWhite:0.36,alpha:1).setStroke()
for i in 0...8 {
let mark = NSBezierPath()
if vertical { let y = rect.minY+rect.height*Double(i)/8;mark.move(to:NSPoint(x:rect.midX-21,y:y));mark.line(to:NSPoint(x:rect.midX+21,y:y)) }
else { let x = rect.minX+rect.width*Double(i)/8;mark.move(to:NSPoint(x:x,y:rect.midY-13));mark.line(to:NSPoint(x:x,y:rect.midY+13)) }
mark.lineWidth = 1;mark.stroke()
}
let groove = vertical ? NSRect(x:rect.midX-2,y:rect.minY,width:4,height:rect.height) : NSRect(x:rect.minX,y:rect.midY-2,width:rect.width,height:4)
NSColor(calibratedWhite:0.025,alpha:1).setFill();NSBezierPath(roundedRect:groove,xRadius:2,yRadius:2).fill()
}
override func drawTickMarks() {}
override func drawKnob(_ rect: NSRect) {
let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0)
let cap = vertical ? NSRect(x:rect.midX-21,y:rect.midY-6,width:42,height:12) : NSRect(x:rect.midX-6,y:rect.midY-14,width:12,height:28)
let path = NSBezierPath(roundedRect:cap,xRadius:4,yRadius:4)
NSGradient(starting:NSColor(calibratedWhite:0.60,alpha:1),ending:NSColor(calibratedWhite:0.30,alpha:1))?.draw(in:path,angle:90)
NSColor(calibratedWhite:0.75,alpha:0.6).setStroke()
let marker = NSBezierPath();if vertical { marker.move(to:NSPoint(x:cap.minX+6,y:cap.midY));marker.line(to:NSPoint(x:cap.maxX-6,y:cap.midY)) } else { marker.move(to:NSPoint(x:cap.midX,y:cap.minY+5));marker.line(to:NSPoint(x:cap.midX,y:cap.maxY-5)) };marker.lineWidth = 1;marker.stroke()
}
}
struct DJContinuousConsole: View {
@ObservedObject var system: DJSystem
let loadA: () -> Void
let loadB: () -> Void
var body: some View {
VStack(spacing:8) {
HStack(spacing:10) {
DJDeckDropZone(system:system,letter:"A") { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10))
DJDeckDropZone(system:system,letter:"B") { DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:10))
}.frame(height:82)
VStack(spacing:2) {
DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) }
DJDeckDropZone(system:system,letter:"B") { DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }
}.frame(minHeight:64,maxHeight:.infinity).background(.black).clipShape(RoundedRectangle(cornerRadius:8)).clipped()
HStack(alignment:.top,spacing:10) {
performance(system.a)
mixer.frame(width:292)
performance(system.b)
}.frame(height:252)
}.accessibilityIdentifier("dj.performance.workspace")
}
private func performance(_ deck: DJChannel) -> some View {
VStack(spacing:6) {
HStack { Text("DECK \(deck.letter)").foregroundStyle(DJStyle.color(deck.letter));Spacer();Text("PERFORMANCE").foregroundStyle(.secondary) }.font(.system(size:9,weight:.bold)).tracking(1.2)
DJDeckDropZone(system:system,letter:deck.letter) { DJConsoleWing(deck:deck,player:deck.player,system:system) }
Divider().overlay(.white.opacity(0.04))
DJConsoleTransport(deck:deck,player:deck.player,system:system)
}.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity,alignment:.top)
.background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
.overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(DJStyle.color(deck.letter)).frame(height:2).padding(.horizontal,14) }
}
private var mixer: some View {
VStack(spacing:8) {
HStack { Text("CHANNEL MIXER");Spacer();Text(system.routing.mode.uppercased()).foregroundStyle(.secondary) }.font(.system(size:8,weight:.bold)).tracking(1)
HStack(alignment:.top,spacing:12) {
DJMixerChannel(deck:system.a,player:system.a.player,system:system)
Divider().frame(height:152)
DJMixerChannel(deck:system.b,player:system.b.player,system:system)
}.font(.system(size:9,weight:.bold))
HStack(spacing:8) {
Text("A").foregroundStyle(DJStyle.a)
DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:30).disabled(system.routing.mode == "External")
Text("B").foregroundStyle(DJStyle.b)
Button { system.crossfader = 0.5 } label: { Image(systemName:"scope") }.buttonStyle(.plain).help("Centre crossfader")
}.font(.system(size:10,weight:.semibold))
}.padding(12).frame(maxHeight:.infinity,alignment:.top).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
}
}
private struct DJMixerChannel: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
let system: DJSystem
var body: some View {
VStack(spacing:4) {
Text(deck.letter).foregroundStyle(DJStyle.color(deck.letter))
DJDeckSurfaces(deck:deck,player:player,system:system).tonePanel
}
}
}
private struct DJTrackHeader: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
let load: () -> Void
private var color: Color { DJStyle.color(deck.letter) }
var body: some View {
HStack(spacing:10) {
if deck.letter == "A" { artwork }
VStack(alignment:.leading,spacing:5) {
HStack {
VStack(alignment:.leading,spacing:2) {
Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1)
HStack(spacing:6) {
Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1)
TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM").help("Set the track’s original BPM to enable beat sync and beat loops")
Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary)
Button("Tap") { deck.tap() }.controlSize(.mini).help("Tap along with the beat to set this track’s BPM")
}.font(.system(size:10)).disabled(!player.canJog)
}
Spacer(minLength:4)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()).help("Track time remaining")
Button("Load",action:load).controlSize(.mini).help("Load the selected DJ library track into deck \(deck.letter)")
}
DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22).help("Track overview: click or drag to seek to a position in the song")
}
if deck.letter == "B" { artwork }
}.padding(10).frame(maxWidth:.infinity)
}
private var artwork: some View {
ZStack {
RoundedRectangle(cornerRadius:4).fill(color.opacity(0.12))
if let image = deck.artwork { Image(nsImage:image).resizable().scaledToFill() }
else { Image(systemName:"music.note").font(.system(size:26)).foregroundStyle(color) }
}.frame(width:62,height:62).clipped().clipShape(RoundedRectangle(cornerRadius:4))
}
}
private struct DJConsoleWing: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
var body: some View {
let face = DJDeckSurfaces(deck:deck,player:player,system:system)
HStack(spacing:18) {
face.tempoStrip
Spacer(minLength:0)
ZStack {
DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:112,height:112)
VStack(spacing:3) {
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:19,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter))
Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary)
}.frame(width:76,height:76).background(DJStyle.background,in:Circle()).allowsHitTesting(false)
}
Spacer(minLength:0)
}.frame(maxWidth:.infinity).frame(height:130).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
}
}
private struct DJConsoleTransport: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
private var playButton: some View {
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) }
.foregroundStyle(player.playing ? Color.green : Color.white).help("Play or pause deck \(deck.letter)").accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play")
}
private var cueButton: some View {
Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22)
.help("Pause and return to the main cue at \(DJStyle.stamp(player.cuePosition)). Right-click to set or clear it.")
.contextMenu { Button("Set Cue Here") { player.setCue() };Button("Clear Main Cue") { player.clearCue() } }
}
private var loopButtons: some View {
HStack(spacing:0) {
Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }.help("Halve the loop length; updates an active loop at its next boundary")
Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle a \(deck.loopBeats)-beat loop; requires confirmed BPM")
Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }.help("Double the loop length; updates an active loop at its next boundary")
}
}
private var headphones: some View {
Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white)
.help("Toggle deck \(deck.letter) headphone preview using the configured cue output")
}
private var clearMarkers: some View {
Menu {
Menu("Clear Individual Hot Cue") {
ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) }
}
Button("Clear All Hot Cues") { player.clearHotCues() }.disabled(player.hotCues.allSatisfy{$0 == nil})
Button("Clear Main Cue") { player.clearCue() }.disabled(player.cuePosition == 0)
Button("Clear Loop Markers") { player.clearLoop() }.disabled(player.loopIn == nil && player.loopOut == nil)
Divider()
Button("Clear All Cue & Loop Markers") { player.clearDeckMarkers() }
} label: { Image(systemName:"eraser") }
.help("Clear individual hot cues, all hot cues, the main cue, loop markers or all markers without unloading the track")
.accessibilityLabel("Deck \(deck.letter) clear markers")
}
var body: some View {
VStack(spacing:6) {
HStack(spacing:8) { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }.controlSize(.regular)
HStack(spacing:4) {
ForEach(0..<4) { index in
Button("\(index+1)") {
if NSEvent.modifierFlags.contains(.option) { player.hotCue(index,clear:true) }
else { system.hotCue(index,deck:deck) }
}.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter))
.help(player.hotCues[index].map{"Hot cue \(index+1) at \(DJStyle.stamp($0)). Click to jump; Option-click or right-click to clear."} ?? "Click to set hot cue \(index+1) at the current position. Option-click or right-click clears it.")
.contextMenu { Button("Clear Hot Cue \(index+1)") { player.hotCue(index,clear:true) }.disabled(player.hotCues[index] == nil) }
}
Menu {
Button("Edit Cue · \(DJStyle.stamp(player.cuePosition))") { system.editCue(deck) }
Button("Set Cue Here") { player.setCue() }
Button("Clear Main Cue") { player.clearCue() }
Button("Edit Loop…") { system.loopEditor = deck.letter }
} label: { Image(systemName:"flag") }.help("Set, edit or clear the main cue; open the loop start/end editor")
Button("IN") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop start at the current position. Right-click to clear the loop markers.")
.contextMenu { Button("Clear Loop Markers") { player.clearLoop() } }
Button("OUT") { player.markLoopOut() }.foregroundStyle(player.loopOut == nil ? .primary : DJStyle.color(deck.letter)).help("Set the loop end and enable the loop. Set IN first; right-click to clear both markers.")
.contextMenu { Button("Clear Loop Markers") { player.clearLoop() } }
clearMarkers
Menu {
Button("Stop Deck") { player.stopDeck() }
Button("Exit Loop / Clear Loop Markers") { player.clearLoop() }
Button("Nudge Back") { player.jog(-0.05) }
Button("Nudge Forward") { player.jog(0.05) }
} label: { Image(systemName:"ellipsis") }.help("Stop the deck, exit its loop or nudge playback by 0.05 seconds")
Spacer(minLength:0)
}.controlSize(.mini)
}.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog)
}
}
private struct DJCentreWaveform: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
let letter: String
let color: Color
var settings = DJPreferences()
var body: some View {
DJScrollingSpectrum(player:player,bpm:deck.bpmConfirmed ? deck.bpm : nil,color:color,seconds:settings.waveformSeconds,label:letter,accessibilityName:"Deck \(letter) scrolling waveform",showTimeGrid:settings.showTimeGrid,spectralWaveforms:settings.spectralWaveforms)
}
}
// The same audio-clock drawing and native mouse interaction serve both studios.
struct DJScrollingSpectrum: View {
@ObservedObject var player: Player
var bpm: Double?
var color: Color = .cyan
var seconds: Double = 16
var label = "KARAOKE"
var accessibilityName = "Karaoke scrolling waveform"
var showTimeGrid = true
var spectralWaveforms = true
var body: some View {
DJNativeSpectrum(player:player,bpm:bpm,color:NSColor(color),seconds:seconds,label:label,showTimeGrid:showTimeGrid,spectralWaveforms:spectralWaveforms)
.clipped()
.accessibilityLabel(accessibilityName).accessibilityValue(DJStyle.stamp(player.time))
.help("Hold the left button and drag left to advance or right to rewind. Shift-drag creates a loop; drag green loop edges to resize or its centre to move it.")
}
}
struct DJPreferencesPane: View {
@ObservedObject var system: DJSystem
@State private var confirmClear = false
let category: String
var body: some View {
Group {
if category == "Devices" {
DJAudioDevicesPane(system:system).padding(26)
} else {
ScrollView {
VStack(alignment:.leading,spacing:26) {
switch category {
case "General": general
case "Sound": sound
case "Library": library
case "Appearance": appearance
case "MIDI": midi
default: advanced
}
}.padding(26).frame(maxWidth:.infinity,alignment:.leading)
}
}
}
.sheet(isPresented:Binding(get:{system.storageInSettings && (system.storageProgress != nil || system.cleanupPlan != nil)},set:{if !$0,!system.movingStorage { system.storageProgress = nil;system.cleanupPlan = nil }})) {
if let progress = system.storageProgress { ImportProgressPanel(progress:progress,studio:"DJ Studio Library",cancel:{system.cancelStorageMove()},close:{system.storageProgress = nil},isMove:system.storageAction == nil,activity:system.storageAction).interactiveDismissDisabled(system.movingStorage) }
else if let plan = system.cleanupPlan { DJCleanupConfirmation(system:system,plan:plan) }
}
.confirmationDialog("Clear the entire DJ library?",isPresented:$confirmClear,titleVisibility:.visible) {
Button("Remove All \(system.songs.count) Tracks",role:.destructive) { system.removeLibraryTracks(Set(system.songs.map(\.id)),fromSettings:true) }
} message: { Text("All DJ track entries and queue entries are removed. Playlists remain empty. Audio files, saved waveforms and original source folders are kept; Clean Unused Files can remove unreferenced managed copies afterwards.") }
.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
}
private var general: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Song Loading") {
VStack(alignment:.leading,spacing:14) {
Toggle("Start playback when loading a track",isOn:$system.settings.autoPlayOnLoad)
Toggle("Reset EQ and filter on song load",isOn:$system.settings.resetEQOnLoad)
Toggle("Protect active deck",isOn:$system.settings.protectPlayingDeck)
Text("Protect active deck requires pausing a playing deck before replacing its track.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Sync Mode") {
VStack(alignment:.leading,spacing:10) { LabeledContent("Sync type",value:"Deck A master");Text("Both decks follow deck A’s effective BPM while Sync is active. A second click restores their previous tempos. BPM can be estimated from audio, entered or tapped; estimates may need correction. Beat-grid phase alignment is not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12)
}
PreferencesSection("Tempo") {
HStack(spacing:24) { Picker("Slider range",selection:$system.settings.tempoRange) { Text("±8%").tag(8.0);Text("±16%").tag(16.0);Text("±50%").tag(50.0) }.frame(width:250);Toggle("Invert slider",isOn:$system.settings.invertTempo) }.padding(12)
}
}
}
private var sound: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Mixer") {
VStack(alignment:.leading,spacing:16) {
Picker("Crossfader curve",selection:$system.settings.crossfaderCurve) { Text("Equal power").tag("Equal power");Text("Linear").tag("Linear") }.frame(width:380)
LabeledContent("EQ type",value:"Three-band shelf / parametric EQ")
LabeledContent("Filter",value:"Low-pass / high-pass · centre bypass")
}.padding(12)
}
PreferencesSection("Audio Output") {
VStack(alignment:.leading,spacing:12) {
Picker("Output headroom",selection:$system.settings.headroomDB) { Text("None").tag(0.0);Text("−3 dB").tag(3.0);Text("−6 dB").tag(6.0);Text("−9 dB").tag(9.0) }.frame(width:350)
Text("Headroom reduces both DJ decks before the master mix. An audio limiter and automatic gain analysis are not implemented.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
}
}
private var library: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Library Maintenance") {
VStack(alignment:.leading,spacing:14) {
HStack { Button("Clear DJ Library…",role:.destructive) { confirmClear = true }.disabled(system.songs.isEmpty);Button("Clean Unused Files…") { system.findUnusedFiles() } }
Text("Clear removes library entries while keeping your files. Cleanup previews unused DJ-managed copies and stale waveforms before moving them to Trash. Referenced original files are excluded.").font(.caption).foregroundStyle(.secondary)
}.padding(12).disabled(system.movingStorage || system.importing || system.generatingWaveforms || system.namesBusy)
}
PreferencesSection("Import Mode") {
VStack(alignment:.leading,spacing:12) {
Picker("Media files",selection:$system.copyImportedMedia) { Text("Reference files in their original location").tag(false);Text("Copy files into DJ storage").tag(true) }.pickerStyle(.radioGroup)
Text("Referencing avoids duplicate media. Keep the source drive connected. Saved waveforms stay in your selected waveform folder. Audio inside ZIP archives is extracted into DJ storage.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Media and Waveform Storage") {
VStack(alignment:.leading,spacing:14) {
LabeledContent("Media") { Text(system.mediaDirectory.path).textSelection(.enabled) }
LabeledContent("Waveforms") { Text(system.waveformDirectory.path).textSelection(.enabled) }
Button("Move Media and Waveforms…") { system.chooseStorage() }.disabled(system.movingStorage || system.importing || system.generatingWaveforms)
Text("Choose a folder on an external drive. Files are copied and verified before originals are removed. The DJ database and playlists stay on this Mac. Stop playback before moving files.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Independent DJ Collection") {
VStack(alignment:.leading,spacing:14) {
LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)")
Text(system.root.path).font(.callout).textSelection(.enabled)
Button("Show DJ library in Finder") { NSWorkspace.shared.open(system.root) }
Text("Import from the DJ browser. Files and playlists are stored separately from karaoke. Imports scan subfolders and verify copied audio. BPM is estimated from audio on load; key analysis, Spotify playback and watched-folder imports are not implemented.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Library Display") {
VStack(spacing:14) { HStack { Text("Font size").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryFont,in:10...16,step:1);Text("\(Int(system.settings.libraryFont)) pt").frame(width:45) };HStack { Text("Row height").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryRowHeight,in:24...44,step:2);Text("\(Int(system.settings.libraryRowHeight)) pt").frame(width:45) } }.padding(12)
}
}
}
private var appearance: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Jog Wheels") { Toggle("Attach rotating tape marker",isOn:$system.settings.platterMarker).padding(12) }
PreferencesSection("Waveforms") {
VStack(alignment:.leading,spacing:14) {
Toggle("Use frequency colours in scrolling waveforms",isOn:$system.settings.spectralWaveforms)
Toggle("Show time grid",isOn:$system.settings.showTimeGrid)
Toggle("Dim inactive deck",isOn:$system.settings.dimInactiveDeck)
Picker("Visible window",selection:$system.settings.waveformSeconds) { ForEach([4.0,8,16,32],id:\.self) { Text("\(Int($0)) seconds").tag($0) } }.frame(width:340)
}.padding(12)
}
}
}
private var midi: some View {
VStack(alignment:.leading,spacing:20) {
PreferencesSection("MIDI Controller") {
Text("Select a source and learn controls using the MIDI button above the DJ console. Mappings save per controller. Standard note buttons and 7-bit CC controls are supported; jog encoders can use relative modes.").padding(12)
}
DJMappingSheet(midi:system.midi,embedded:true)
}
}
private var advanced: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Cue Points & Loops") {
VStack(alignment:.leading,spacing:14) {
Toggle("Start playback when triggering a hot cue",isOn:$system.settings.autoPlayHotCue)
Text("Hot cues and loop points belong to the loaded track session. Manual IN/OUT loops and BPM-based beat loops are supported. Beat-grid quantization and saving cue/loop data per song are not implemented.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}
PreferencesSection("Playback") { Text("Platter dragging performs seek-based jogging. DVS, touch scratching, recording, Neural Mix and cloud synchronization are not implemented.").font(.callout).foregroundStyle(.secondary).padding(12) }
}
}
}
private struct DJConsoleButtonStyle: ButtonStyle {
let accent: Color?
let active: Bool
@Environment(\.controlSize) private var size
@Environment(\.isEnabled) private var enabled
init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active }
func makeBody(configuration: Configuration) -> some View {
configuration.label.padding(.horizontal,size == .mini ? 5 : 7).padding(.vertical,size == .mini ? 3 : 6)
.background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5))
.overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2))
.opacity(enabled ? 1 : 0.35)
}
}
struct DJReplacement: Identifiable {
let id = UUID()
let deck: String
let song: Song?
let urls: [URL]
var title: String { song?.title ?? urls.first?.lastPathComponent ?? "track" }
}
@MainActor extension DJSystem {
func acceptDrop(_ providers: [NSItemProvider],into letter: String) -> Bool {
let files = providers.filter{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)}
if !files.isEmpty {
Task { @MainActor in
do { let urls = try await DJFinderDrop.urls(files);requestFileLoad(urls,into:letter) }
catch { self.error = error.localizedDescription }
}
return true
}
guard let provider = providers.first(where:{$0.canLoadObject(ofClass:NSString.self)}) else { return false }
Task { @MainActor in
do {
let reference = try await SongDragReference.load(provider)
guard reference.library == dragSession,let song = songs.first(where:{$0.id == reference.song}) else { throw LibraryError.message("Drag from the DJ library or drop an audio file from Finder.") }
load(song,into:letter)
} catch { self.error = error.localizedDescription }
}
return true
}
func requestFileLoad(_ urls: [URL],into letter: String,override: Bool = false) {
guard !urls.isEmpty else { return }
if channel(letter).player.playing,!override { pendingReplacement = .init(deck:letter,song:nil,urls:urls);return }
if importing {
queuedFileLoads.append((urls,letter,override,channel(letter).song?.id))
status = "Queued file drop for deck \(letter) · waiting for the current import";return
}
importFiles(urls,loadInto:letter,overridePlaying:override)
}
func confirmReplacement(_ captured: DJReplacement? = nil) {
guard let request = captured ?? pendingReplacement else { return };pendingReplacement = nil
if let song = request.song { load(song,into:request.deck,overridePlaying:true);continueFileLoads() }
else { requestFileLoad(request.urls,into:request.deck,override:true) }
}
}
/// Conservative onset-envelope estimate; silence and steady tones return no BPM.
enum DJBPM {
static func estimate(_ envelope: [Double],rate: Double = 200) -> Double? {
guard envelope.count >= Int(rate*4),rate > 0 else { return nil }
let average = envelope.reduce(0,+)/Double(envelope.count)
guard average > 0.00001 else { return nil }
let onset = envelope.indices.map { i in i == 0 ? 0 : max(0,envelope[i]-envelope[i-1]) }
let energy = onset.reduce(0){$0+$1*$1}
guard sqrt(energy/Double(onset.count)) > average*0.035 else { return nil }
var bestScore = 0.0,bestLag = 0
let lower = Int(rate*60/180),upper = Int(rate*60/70)
for lag in lower...upper {
var product = 0.0,first = 0.0,second = 0.0
for i in lag..<onset.count { product += onset[i]*onset[i-lag];first += onset[i]*onset[i];second += onset[i-lag]*onset[i-lag] }
let score = product/max(0.00000001,sqrt(first*second))
if score > bestScore { bestScore = score;bestLag = lag }
}
guard bestScore > 0.12,bestLag > 0 else { return nil }
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..<Int(buffer.frameLength) {
var sample = 0.0
for c in 0..<Int(format.channelCount) { let value = Double(channels[c][i*Int(buffer.stride)]);sample += value*value }
sum += sample/Double(format.channelCount);count += 1
if count == hop { envelope.append(sqrt(sum/Double(count)));count = 0;sum = 0 }
}
}
return estimate(envelope,rate:format.sampleRate/Double(hop))
}
}
enum DJTrackInfo {
static func artwork(_ song: Song) async -> NSImage? {
let asset = AVURLAsset(url:URL(fileURLWithPath:song.audio))
if let items = try? await asset.load(.commonMetadata),let item = items.first(where:{$0.commonKey == .commonKeyArtwork}),let data = try? await item.load(.dataValue),data.count <= 20_000_000,let image = NSImage(data:data) { return image }
return sidecar(song).flatMap{NSImage(contentsOf:$0)}
}
static func sidecar(_ song: Song) -> URL? {
let sources = [song.originalPath,song.audio].filter{!$0.isEmpty}.map{URL(fileURLWithPath:$0)}
for source in sources {
let folder = source.deletingLastPathComponent(),stem = source.deletingPathExtension().lastPathComponent.lowercased()
guard let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.fileSizeKey,.isRegularFileKey],options:[.skipsHiddenFiles]) else { continue }
let candidates = files.filter { url in
guard ["jpg","jpeg","png"].contains(url.pathExtension.lowercased()),let values = try? url.resourceValues(forKeys:[.fileSizeKey,.isRegularFileKey]) else { return false }
return values.isRegularFile == true && (values.fileSize ?? Int.max) <= 20_000_000
}
func priority(_ url: URL) -> Int { let name = url.deletingPathExtension().lastPathComponent.lowercased();return name == stem ? 0 : ["cover","folder","front","artwork"].contains(name) ? 1 : 2 }
if let first = candidates.sorted(by:{priority($0) == priority($1) ? $0.lastPathComponent < $1.lastPathComponent : priority($0) < priority($1)}).first { return first }
}
return nil
}
}
private struct DJCueEditor: View {
@ObservedObject var system: DJSystem
var body: some View {
let deck = system.channel(system.cueEditor ?? "A")
VStack(alignment:.leading,spacing:18) {
Text("Deck \(deck.letter) · Cue Point").font(.title2.bold())
Text(deck.song?.title ?? "No track").foregroundStyle(.secondary)
HStack { Text("Position in seconds");TextField("Seconds",value:$system.cueDraft,format:.number.precision(.fractionLength(2))).textFieldStyle(.roundedBorder).frame(width:100);Text("/ "+DJStyle.stamp(deck.player.duration)).foregroundStyle(.secondary) }
Slider(value:$system.cueDraft,in:0...max(0.001,deck.player.duration)).accessibilityLabel("Cue point position")
Button("Use Current Playhead") { system.cueDraft = deck.player.time }.help("Use the current playback position as the main cue")
Text("Saving changes the cue point without moving or pausing playback. The CUE button pauses and returns to this position.").font(.caption).foregroundStyle(.secondary)
HStack { Button("Cancel") { system.cueEditing = false }.help("Close without changing the cue");Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction).help("Save the main cue without moving or pausing playback") }
}.padding(24).frame(width:470)
}
}
/// Protocol facts adapted from josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx.
/// XML is authoritative for the right Mode button (95 00); the JS table has a conflicting entry.
struct MixtrackGoProtocol {
static var shutdownBytes: [UInt8] {
var bytes: [UInt8] = [0x9E,0x7F,0]
for i in 0..<2 {
for note in [0,1,2,3,4,5,0x46,0x48,0x54] { bytes += [UInt8(0x90+i),UInt8(note),0] }
bytes += [UInt8(0x80+i),0x1B,0,0x9F,UInt8(2+i),0,0x9F,UInt8(0x48+i),0]
for note in Array(1...4)+Array(0x14...0x17)+Array(0x1C...0x1F) { bytes += [UInt8(0x94+i),UInt8(note),0] }
}
return bytes+[0x9F,0x46,0,0x9F,0x47,0]
}
struct Event { var action: String;var value: Double = 1;var relative = false }
var modes = [0,0]
var lowEQ = false
private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false]
static func mappings(source: Int32) -> [DJMIDIMapping] {
var result = [DJMIDIMapping]()
func add(_ status: UInt8,_ number: UInt8,_ action: String,_ mode: String = "Absolute") { result.append(.init(source:source,status:status,number:number,action:action,mode:mode)) }
add(0xBF,0x0A,"Master");add(0xBF,0x0C,"Cue volume");add(0xBF,0x08,"Crossfader")
add(0xBF,0x00,"Browse","Relative signed");add(0x9F,0x07,"Preview selected")
for i in 0..<2 {
let d = i == 0 ? "A" : "B",note = UInt8(0x90+i),cc = UInt8(0xB0+i)
add(0x9F,UInt8(0x02+i),d+" Load")
add(cc,0x16,d+" Level");add(cc,0x06,d+" Jog ticks","Relative signed")
add(note,0x1B,d+" Headphones");add(note,0x00,d+" Play");add(note,0x01,d+" Cue button");add(note,0x02,d+" Sync")
add(note,0x03,d+" Sync");add(note,0x04,d+" Cue play");add(note,0x05,d+" Cue play")
}
return result
}
mutating func consume(_ message: DJMIDIMessage) -> [Event]? {
let status = message.status,number = message.data1,value = message.data2
if status == 0x9F,number == 0x48 { lowEQ.toggle();return [.init(action:"Notice: Knobs now control "+(lowEQ ? "Low EQ" : "Filter"))] }
if [UInt8(0xB0),0xB1].contains(status) {
let index = Int(status & 1),deck = index == 0 ? "A" : "B"
if number == 0x09 || number == 0x29 {
if number == 0x09 { msb[index] = Int(value);gotMSB[index] = true }
else { lsb[index] = Int(value);gotLSB[index] = true }
guard gotMSB[index],gotLSB[index] else { return [] }
let raw = (msb[index] << 7) | lsb[index],signed = max(-1,min(1,Double(raw-8192)/8191))
return [.init(action:deck+" Tempo",value:0.5+signed/2)]
}
if number == 0x1A { return [.init(action:deck+(lowEQ ? " Low" : " Filter"),value:Double(value)/127)] }
}
if [UInt8(0x94),0x95].contains(status) {
let index = Int(status & 1),deck = index == 0 ? "A" : "B"
if number == 0 { modes[index] = (modes[index]+1)%5;return [.init(action:"Notice: "+deck+" pad mode · "+["Hot cues","Loops","FX (unsupported)","Sampler (unsupported)","Stems (unsupported)"][modes[index]])] }
let shifted = (0x1C...0x1F).contains(number),first: UInt8 = shifted ? 0x1C : 0x14
guard (first...(first+3)).contains(number) else { return nil }
let pad = Int(number-first)
if modes[index] == 0 { return [.init(action:deck+(shifted ? " Clear cue " : " Hot cue ")+String(pad+1))] }
if modes[index] == 1 { return [.init(action:deck+(shifted ? " Loop off" : " Loop "+String(1 << pad)))] }
return [.init(action:"Notice: This pad mode requires an FX, sampler or stems engine that is not implemented.")]
}
if [UInt8(0x90),0x91].contains(status),[UInt8(0x46),0x47,0x48,0x49].contains(number) { return [.init(action:"Notice: Acapella/instrumental stems are not implemented.")] }
return nil
}
func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) -> [UInt8] {
var bytes = [UInt8]()
for i in 0..<2 {
let status = UInt8(0x90+i),pad = UInt8(0x94+i),loaded = i == 0 ? aLoaded : bLoaded,playing = i == 0 ? aPlaying : bPlaying
bytes += [status,0x00,playing ? 127 : (loaded ? 1 : 0),status,0x01,loaded ? 1 : 0,status,0x02,sync ? 127 : 0]
bytes += [UInt8(0x80+i),0x1B,cue == (i == 0 ? "A" : "B") ? 127 : 1]
bytes += [status,0x04,playing ? 127 : 1,status,0x05,loaded ? 127 : 1,status,0x03,sync ? 127 : 1,0x9F,UInt8(0x02+i),loaded ? 127 : 1]
for index in 0..<4 {
let cues = i == 0 ? aCues : bCues,loop = i == 0 ? aLoop : bLoop
let on = modes[i] == 0 ? (cues.indices.contains(index) && cues[index]) : modes[i] == 1 && loop
bytes += [pad,UInt8(0x14+index),on ? 127 : 1]
}
for mode in 0..<4 {
let on = modes[i] == 0 ? mode == 0 : modes[i] == 1 ? mode == 1 : modes[i] == 2 ? (mode == 1 || mode == 2) : modes[i] == 3 ? mode == 2 : mode == 3
bytes += [pad,UInt8(mode+1),on ? 127 : 1]
}
}
return bytes
}
}
enum DJFinderDrop {
static func urls(_ providers: [NSItemProvider]) async throws -> [URL] {
var urls = [URL]()
for provider in providers {
let data: Data? = await withCheckedContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in continuation.resume(returning:data) }
}
if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { urls.append(url);continue }
let url: URL = try await withCheckedThrowingContinuation { continuation in
provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in
if let url = item as? URL,url.isFileURL { continuation.resume(returning:url) }
else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url) }
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read an absolute local file URL from Finder. Drop the audio file itself or import it with Import Files / Folder.")) }
}
}
urls.append(url)
}
return urls
}
}
// BPM-relative guides start at source time zero; these are not analysed downbeats.
enum DJWaveMarkers {
static func beats(start: Double,span: Double,bpm: Double?,width: Double) -> [Int] {
guard let bpm,bpm.isFinite,bpm > 0,start.isFinite,span.isFinite,span > 0,width > 0 else { return [] }
let interval = 60/bpm,first = max(0,Int(ceil(start/interval))),last = Int(floor((start+span)/interval))
guard last >= first else { return [] }
let strideBy = max(1,Int(ceil(span/interval/max(1,width/5))))
return Array(stride(from:first,through:last,by:strideBy))
}
static func draw(context: inout GraphicsContext,size: CGSize,start: Double,span: Double,bpm: Double?,loop: ClosedRange<Double>?) {
guard span > 0 else { return }
if let bpm {
for beat in beats(start:start,span:span,bpm:bpm,width:size.width) {
let x = (Double(beat)*60/bpm-start)/span*size.width
context.fill(Path(CGRect(x:x,y:0,width:beat % 4 == 0 ? 1.2 : 0.6,height:size.height)),with:.color(beat % 4 == 0 ? .yellow.opacity(0.85) : .white.opacity(0.4)))
if beat % 4 == 0,size.height >= 45 {
context.draw(Text("\(beat/4+1)").font(.system(size:9,weight:.semibold)).foregroundColor(.yellow),at:CGPoint(x:x+5,y:9),anchor:.leading)
}
}
}
if let loop {
let left = max(0,(loop.lowerBound-start)/span*size.width),right = min(size.width,(loop.upperBound-start)/span*size.width)
if right > left {
let rect = CGRect(x:left,y:0,width:right-left,height:size.height)
context.fill(Path(rect),with:.color(.green.opacity(0.22)))
context.stroke(Path(rect.insetBy(dx:0.8,dy:0.8)),with:.color(.green),lineWidth:1.5)
}
for (time,label) in [(loop.lowerBound,"IN"),(loop.upperBound,"OUT")] {
let x = (time-start)/span*size.width
if x >= 0,x <= size.width {
context.fill(Path(CGRect(x:x,y:0,width:2,height:size.height)),with:.color(.green))
context.fill(Path(CGRect(x:x-3,y:size.height/2-7,width:7,height:14)),with:.color(.green))
context.fill(Path(CGRect(x:x,y:size.height/2-5,width:1,height:10)),with:.color(.black))
context.draw(Text(label).font(.system(size:8,weight:.bold)).foregroundColor(.green),at:CGPoint(x:min(size.width-12,max(12,x)),y:7))
}
}
}
}
}
private struct DJGraphicEQDialog: View {
@ObservedObject var system: DJSystem
@Environment(\.dismiss) private var dismiss
private let labels = ["31","62","125","250","500","1k","2k","4k","8k","16k"]
var body: some View {
VStack(alignment:.leading,spacing:22) {
Label("DJ Graphic Equaliser",systemImage:"slider.vertical.3").font(.title2.bold()).foregroundStyle(DJStyle.a)
Text("Shared by both decks and all tracks. Your deck EQ controls remain independent.").font(.callout).foregroundStyle(.secondary)
HStack {
Toggle("Enabled",isOn:$system.graphicEQ.enabled).toggleStyle(.switch).help("Enable or bypass the shared ten-band equaliser")
Spacer()
Picker("Preset",selection:Binding(get:{system.graphicEQ.preset},set:{system.chooseGraphicPreset($0)})) {
ForEach(DJGraphicEQSettings.presets.map(\.0),id:\.self) { Text($0).tag($0) }
if system.graphicEQ.preset == "Custom" { Text("Custom").tag("Custom") }
}.frame(width:230)
Button("Reset") { system.chooseGraphicPreset("Flat") }.help("Return every graphic EQ band to 0 dB")
}
HStack(alignment:.top,spacing:14) {
ForEach(0..<10,id:\.self) { index in
VStack(spacing:9) {
Text(String(format:"%+.0f",system.graphicEQ.gains[index])).font(.caption.monospacedDigit()).foregroundStyle(DJStyle.a)
DJHardwareFader(value:Binding(get:{(system.graphicEQ.gains[index]+12)/24},set:{ value in
var next = system.graphicEQ;next.gains[index] = value*24-12;next.preset = "Custom";system.graphicEQ = next
}),label:"Graphic EQ \(labels[index]) hertz",vertical:true).frame(width:40,height:150)
Text(labels[index]).font(.caption)
}
}
}.padding(18).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:12))
HStack { Text("±12 dB · Changes apply immediately · Saved automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(26).frame(width:660).background(DJStyle.background).preferredColorScheme(.dark)
}
}
private struct DJTrackMetadataDialog: View {
@ObservedObject var system: DJSystem
let song: Song
@State private var title = ""
@State private var artist = ""
@State private var album = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:18) {
Text("DJ Song Info").font(.title2.bold())
TextField("Title",text:$title).textFieldStyle(.roundedBorder)
TextField("Artist",text:$artist).textFieldStyle(.roundedBorder)
TextField("Album",text:$album).textFieldStyle(.roundedBorder)
Text("Changes update your DJ library; the original file is kept.").font(.caption).foregroundStyle(.secondary)
HStack { Spacer();Button("Cancel") { dismiss() };Button("Save") {
system.perform {
try system.library?.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:song.label,rating:song.rating)
try system.refresh()
if let updated = system.songs.first(where:{$0.id == song.id}) {
for deck in [system.a,system.b] where deck.song?.id == song.id { deck.updateTrackMetadata(updated) }
}
dismiss()
}
}.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) }
}.padding(26).frame(width:480).onAppear { title = song.title;artist = song.artist;album = song.album }
}
}
private struct DJCleanupConfirmation: View {
@ObservedObject var system: DJSystem
let plan: DJCleanupPlan
var body: some View {
VStack(alignment:.leading,spacing:18) {
Label("Clean DJ Library Storage",systemImage:"trash").font(.title2.bold())
Text("\(plan.files.count) unused files · \(ByteCountFormatter.string(fromByteCount:plan.bytes,countStyle:.file))").font(.headline)
Text("Only unreferenced DJ-managed media and stale waveform caches are listed. Original source folders and files still referenced by the library are excluded.").font(.callout).foregroundStyle(.secondary)
ScrollView { LazyVStack(alignment:.leading,spacing:6) { ForEach(Array(plan.files.prefix(100))) { Text($0.url.path).font(.caption.monospaced()).textSelection(.enabled) } } }.frame(height:210)
if plan.files.count > 100 { Text("Showing the first 100 files.").font(.caption).foregroundStyle(.secondary) }
HStack { Button("Cancel") { system.cleanupPlan = nil };Spacer();Button("Move Unused Files to Trash",role:.destructive) { system.cleanUnusedFiles(plan) }.buttonStyle(.borderedProminent).disabled(plan.files.isEmpty) }
}.padding(26).frame(width:680).preferredColorScheme(.dark)
}
}