1623 lines
107 KiB
Swift
1623 lines
107 KiB
Swift
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))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|