Files
KMix_Pro/KJamsClone/DJWorkstation.swift
T
2026-11-05 21:27:41 +00:00

1623 lines
107 KiB
Swift
Raw 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 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(persistsMix:false)
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 { updateLevel() } }
var crossWeight: Double = 1 { didSet { updateLevel() } }
private var taps = [Double]()
init(_ letter: String) { self.letter = letter;player.autoFade = false;updateLevel() }
func updateLevel() { player.volume = min(1,max(0,level * master * crossWeight)) }
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 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 {
let a = DJChannel("A"),b = DJChannel("B")
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 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 private(set) var importing = false
private var importTask: Task<Void,Never>?
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")
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"));try refresh()
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") ?? [] }
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,!urls.isEmpty else { return }
importing = true;importProgress = .init()
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),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)
importedIDs = try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { 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 else { return };self.importing = false;self.importTask = nil;self.importProgress = result
self.perform { try self.refresh() }
if let failure = result.failure { 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) }
self.continueFileLoads()
}
}
}
func cancelImport() { queuedFileLoads = [];importTask?.cancel() }
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() }
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 }
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
perform {
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() }
try deck.load(song)
let route = routing.route(letter)
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } 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) {
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 target = a.bpm*a.player.tempo,rate = target/b.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)
syncActive = true;updatingSync = true;b.player.tempo = rate;updatingSync = false
status = String(format:"SYNC ON · Both decks %.1f BPM · A is master",target)
}
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 rate = a.bpm*a.player.tempo/b.bpm
guard rate.isFinite,(0.5...2).contains(rate) else { disableSync();return }
if abs(b.player.tempo-rate) > 0.00001 { updatingSync = true;b.player.tempo = rate;updatingSync = false }
}
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
if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate }
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": if syncActive,d.letter == "B" { a.player.tempo = b.bpm*settings.tempo(at:value)/a.bpm;maintainSync() } else { p.tempo = settings.tempo(at:value) }
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)
let added = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,$0.count,$0.baseAddress!) }
if added != nil { 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.055,green:0.065,blue:0.085)
static let panel = Color(red:0.095,green:0.11,blue:0.14)
static let a = Color(red:0.20,green:0.80,blue:0.95)
static let b = Color(red:1.0,green:0.66,blue:0.22)
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)
}
}
struct DJConsole: View {
@ObservedObject var system: DJSystem
@State private var query = ""
@State private var playlist: Int64?
@State private var playlistIDs = [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
private var songs: [Song] {
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} }
return all
}
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
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true)
HStack(alignment:.top,spacing:12) {
browser.frame(maxWidth:.infinity,maxHeight:.infinity)
DJQueuePane(system:system,automix:system.automix).frame(width:310).frame(maxHeight:.infinity)
}.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
Spacer()
if system.importing { ProgressView().controlSize(.mini);Button("Cancel") { system.cancelImport() } }
else { Button("Dismiss") { system.importProgress = nil } }
}.font(.caption).controlSize(.mini)
}
}.padding(14).frame(maxWidth:.infinity,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.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:$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() }
.onChange(of:selected) { _ in connectMIDI() }
.onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil }
.onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onChange(of:query) { _ in page = 0 }
}
private var header: some View {
HStack {
Image(systemName:"waveform.path").foregroundStyle(DJStyle.a)
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
Spacer()
if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) }
}.controlSize(.small)
}
private var browser: some View {
VStack(spacing:5) {
HStack(spacing:8) {
Picker("DJ library",selection:$playlist) { Text("All music").tag(nil as Int64?);ForEach(system.playlists) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:175)
Menu {
Button("New Playlist…") { renaming = false;playlistName = "";playlistEditor = true }
if let playlist {
Button("Rename Playlist…") { renaming = true;playlistName = system.playlists.first{$0.id == playlist}?.name ?? "";playlistEditor = true }
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) }
}
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
Button { system.automix.enqueue(songs) } label: { Image(systemName:"text.badge.plus") }.help("Add this library or playlist to the DJ queue").disabled(songs.isEmpty)
Button("Import Files / Folder…") { importMedia() }
Spacer(minLength:0)
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0)
Text("\(min(page,pages-1)+1)/\(pages) · \(songs.count)").font(.caption.monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
List(selection:$selected) {
ForEach(visible) { song in
HStack(spacing:8) {
Image(systemName:"music.note").foregroundStyle(.secondary)
Text(song.title).font(.system(size:system.settings.libraryFont,weight:.medium)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading)
Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading)
Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35)
Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue")
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini)
.onDrag { system.dragReference(song.id).provider() }
.contextMenu { playlistMenu(song) }
}
if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) }
}.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6))
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
Button("Add to DJ Queue") { system.automix.enqueue([song]) }
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
if let playlist {
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)} ?? [] }
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;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:5) {
Button("SYNC") { system.sync(deck) }.font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(color,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Toggle both decks to deck A’s BPM; click again to restore their original tempos").accessibilityIdentifier("dj.\(deck.letter).sync")
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : "—").font(.system(size:11,weight:.semibold).monospacedDigit())
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:52,height:94)
HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) };Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) } }.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:12) {
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
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:8,height:5) }
}.accessibilityLabel("Deck output meter")
}
}.frame(width:88)
}
}
struct DJKnob: View {
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)}
@State private var initial: Double?
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) }
var body: some View {
VStack(spacing:1) {
ZStack {
Circle().fill(Color.black.opacity(0.35)).overlay { Circle().stroke(.white.opacity(0.12),lineWidth:2) }
Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90))
Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270))
}.frame(width:29,height:29).contentShape(Circle())
.simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil})
Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary)
}.frame(minWidth:36).contentShape(Rectangle())
.highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil })
.accessibilityAction(named:Text("Reset to default")) { value = reset;initial = nil }
.help("\(label): \(format(value)). Drag vertically; double-click to reset.")
.accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value))
.accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) }
}
}
private struct DJPlatter: View {
@ObservedObject var player: Player
let letter: String
let color: Color
var markerEnabled = true
@State private var lastAngle: Double?
var body: some View {
GeometryReader { geometry in
ZStack {
Circle().fill(Color.black.opacity(0.55)).overlay { Circle().stroke(color.opacity(0.4),lineWidth:3) }
ForEach([0.70,0.80,0.90],id:\.self) { scale in Circle().stroke(.white.opacity(0.06),lineWidth:1).scaleEffect(scale) }
ZStack {
Circle().fill(color.opacity(0.15)).frame(width:53,height:53)
Text(letter).font(.system(size:25,weight:.bold,design:.rounded)).foregroundStyle(color)
if markerEnabled {
Rectangle().fill(color).frame(width:5,height:26).offset(y:-geometry.size.height*0.37)
Circle().fill(.white).frame(width:5,height:5).offset(y:-geometry.size.height*0.45)
}
}.rotationEffect(.degrees(player.time*200))
}.contentShape(Circle())
.gesture(DragGesture(minimumDistance:2).onChanged { drag in
let angle = atan2(Double(drag.location.y-geometry.size.height/2),Double(drag.location.x-geometry.size.width/2))
if let previous = lastAngle { var delta = angle-previous;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta / (2 * .pi) * 2) }
lastAngle = angle
}.onEnded{_ in lastAngle = nil})
}.accessibilityLabel("Deck \(letter) jog platter").accessibilityValue(player.playing ? "Rotating" : "Paused").help("Drag around the platter to jog (2 seconds per turn). Rotation follows source playback time.")
}
}
private struct DJWaveform: View {
@State private var frozenStart: Double?
@ObservedObject var player: Player
let color: Color
var bpm: Double?
var body: some View {
GeometryReader { geometry in
Canvas { context,size in
let values = player.waveform
guard !values.isEmpty else { return }
let count = max(1,Int(size.width/2))
for i in 0..<count {
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)),with:.color(color.opacity(Double(i)/Double(count) < player.time/max(0.001,player.duration) ? 0.4 : 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 = player.time/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.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) }
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:0) {
HStack(spacing:0) {
DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA)
Divider()
DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB)
}.frame(height:92).background(Color.black.opacity(0.4))
Divider()
HStack(spacing:0) {
DJConsoleWing(deck:system.a,player:system.a.player,system:system)
Divider()
VStack(spacing:1) {
DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"A") }
DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"B") }
}.frame(maxWidth:.infinity).background(.black)
Divider()
DJConsoleWing(deck:system.b,player:system.b.player,system:system)
}.frame(height:198).background(Color(white:0.12))
Divider()
HStack(spacing:12) {
DJConsoleTransport(deck:system.a,player:system.a.player,system:system)
VStack(spacing:0) {
DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36).disabled(system.routing.mode == "External")
HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("Centre") { system.crossfader = 0.5 }.buttonStyle(.plain);Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.system(size:9))
}.frame(width:195)
DJConsoleTransport(deck:system.b,player:system.b.player,system:system)
}.padding(.horizontal,12).padding(.vertical,10).background(Color(white:0.14))
}.overlay(Rectangle().stroke(.white.opacity(0.09)))
}
}
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")
Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary)
Button("Tap") { deck.tap() }.controlSize(.mini)
}.font(.system(size:10)).disabled(!player.canJog)
}
Spacer(minLength:4)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit())
Button("Load",action:load).controlSize(.mini)
}
DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22)
}
if deck.letter == "B" { artwork }
}.padding(10).frame(maxWidth:.infinity)
.contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) }
}
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:10) {
if deck.letter == "B" { face.tonePanel }
if deck.letter == "A" { face.tempoStrip }
ZStack {
DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:134,height:134)
VStack(spacing:3) {
Text(String(format:"%+.1f%%",(player.tempo-1)*100)).font(.system(size:9)).foregroundStyle(.secondary)
Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:21,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter))
Divider().frame(width:62)
Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary)
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:11).monospacedDigit())
}.frame(width:95,height:95).background(Color(white:0.095),in:Circle()).allowsHitTesting(false)
}
if deck.letter == "A" { face.tonePanel }
if deck.letter == "B" { face.tempoStrip }
}.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1)
.contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) }
}
}
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).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).contextMenu { Button("Set cue here") { player.setCue() } } }
private var loopButtons: some View {
HStack(spacing:0) {
Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) }
Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle beat loop; requires confirmed BPM")
Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) }
}
}
private var headphones: some View { Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) }
var body: some View {
VStack(spacing:6) {
HStack(spacing:8) {
if deck.letter == "A" { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) }
else { Spacer(minLength:0);headphones;loopButtons;cueButton;playButton }
}.controlSize(.regular)
HStack(spacing:6) {
if deck.letter == "B" { Spacer(minLength:0) }
ForEach(0..<4) { index in Button("\(index+1)") { system.hotCue(index,deck:deck) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)).contextMenu { Button("Clear hot cue") { player.hotCue(index,clear:true) } } }
Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() };Button("Loop ✎") { system.loopEditor = deck.letter }
Menu { Button("Stop") { player.stopDeck() };Button("Exit loop") { player.clearLoop() };Button("Nudge back") { player.jog(-0.05) };Button("Nudge forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") }
if deck.letter == "A" { 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
private var bpm: Double? { deck.bpmConfirmed ? deck.bpm : nil }
var settings = DJPreferences()
@State private var frozenStart: Double?
var body: some View {
GeometryReader { geometry in
Canvas { context,size in
guard !player.waveform.isEmpty,player.duration > 0 else { return }
let span = settings.waveformSeconds,start = frozenStart ?? (player.time-span/2),count = max(1,Int(size.width/2))
for second in 0...8 {
let x = Double(second)/8*size.width
context.fill(Path(CGRect(x:x,y:0,width:0.5,height:size.height)),with:.color(.white.opacity(settings.showTimeGrid ? 0.15 : 0)))
}
for i in 0..<count {
let seconds = start+Double(i)/Double(count)*span
guard seconds >= 0,seconds < player.duration else { continue }
let index = min(player.waveform.count-1,Int(seconds/player.duration*Double(player.waveform.count)))
let height = max(1,Double(player.waveform[index])*size.height*0.85)
var shade = color
if settings.spectralWaveforms,player.waveformBands.indices.contains(index) {
let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high)
shade = Color(hue:Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72,saturation:0.85,brightness:0.95)
}
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade))
}
DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
context.fill(Path(CGRect(x:size.width/2,y:0,width:1.5,height:size.height)),with:.color(.red))
}.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) }
.modifier(DJLoopWaveInteraction(player:player,start:player.time-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart))
}.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time))
}
}
struct DJPreferencesPane: View {
@ObservedObject var system: DJSystem
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)
}
}
}.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("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 ? 6 : 12).padding(.vertical,size == .mini ? 3 : 7)
.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? {
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() }
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 }
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 };Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction) }
}.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 url: URL = try await withCheckedThrowingContinuation { continuation in
provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in
if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url);return }
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(.white.opacity(beat % 4 == 0 ? 0.5 : 0.22)))
}
}
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))
}
}
}
}
}