120 lines
8.6 KiB
Swift
120 lines
8.6 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
// Data-only adapter. djay custom processors, predicates and device scripts are never executed.
|
|
struct DJXMLBinding: Codable, Equatable {
|
|
var status: UInt8
|
|
var number: UInt8
|
|
var action: String
|
|
var mode: String
|
|
var flipped: Bool
|
|
var pickup: Bool
|
|
var key: String { "\(status):\(status & 0xF0 == 0xE0 ? 0 : number)" }
|
|
func matches(_ message: DJMIDIMessage) -> Bool {
|
|
message.status == status && (status & 0xF0 == 0xE0 || message.data1 == number)
|
|
}
|
|
func value(_ message: DJMIDIMessage) -> Double {
|
|
let raw = status & 0xF0 == 0xE0 ? Double(Int(message.data1) | Int(message.data2)<<7)/16383 : DJMIDIMapping.value(message.data2,mode:mode)
|
|
return flipped ? (mode == "Absolute" ? 1-raw : -raw) : raw
|
|
}
|
|
}
|
|
|
|
struct DJXMLProfile: Codable, Identifiable {
|
|
var id: String
|
|
var name: String
|
|
var endpoint: String
|
|
var bindings: [DJXMLBinding]
|
|
var issues: [String]
|
|
var total: Int
|
|
var report: String {
|
|
"\(name)\n\(bindings.count) supported bindings of \(total) control entries.\n\n" +
|
|
"BPM sync, seek-based jog and headphones use KJMix Pro behaviour. Relative encoders use signed or offset MIDI values; djay acceleration curves are not reproduced. Hardware has not been certified. Audio routing is configured separately.\n\n" + issues.joined(separator:"\n")
|
|
}
|
|
static func parse(_ data: Data,filename: String) throws -> DJXMLProfile {
|
|
guard data.count <= 8_000_000 else { throw failure("Mapping file exceeds 8 MB.") }
|
|
guard let root = try PropertyListSerialization.propertyList(from:data,options:[],format:nil) as? [String:Any],
|
|
root["schemeVersion"] as? Int == 1 else { throw failure("Expected a djay mapping XML property list with schemeVersion 1.") }
|
|
let controls = root["controls"] as? [[String:Any]] ?? []
|
|
guard controls.count <= 20000 else { throw failure("Mapping contains too many controls.") }
|
|
var bindings = [DJXMLBinding](),issues = Set<String>(),blocked = Set<String>()
|
|
func address(_ c: [String:Any]) -> (UInt8,UInt8)? {
|
|
guard let channel = c["midiChannel"] as? Int,(0...15).contains(channel),let number = c["midiData"] as? Int,(0...127).contains(number),let type = c["midiMessageType"] as? Int,[1,3,6].contains(type) else { return nil }
|
|
return (UInt8((type == 1 ? 0x90 : type == 3 ? 0xB0 : 0xE0)+channel),UInt8(number))
|
|
}
|
|
for c in controls {
|
|
guard c["headingTitle"] == nil else { continue }
|
|
let path = c["keyPath"] as? String ?? "Unassigned control"
|
|
guard let (status,number) = address(c) else { issues.insert("\(path): unsupported or missing MIDI message/address (including HID). ");continue }
|
|
let key = "\(status):\(status & 0xF0 == 0xE0 ? 0 : number)"
|
|
if c["condition"] != nil || c["modifier"] as? Bool == true || c["hidElementKey"] != nil {
|
|
blocked.insert(key);issues.insert("\(path): conditional, modifier or HID binding requires djay device logic.");continue
|
|
}
|
|
if let mode = c["buttonMode"] as? String,mode != "toggle" {
|
|
blocked.insert(key);issues.insert("\(path): button mode '\(mode)' is not supported.");continue
|
|
}
|
|
guard let action = action(path) else { blocked.insert(key);issues.insert("\(path): no matching KJMix Pro action.");continue }
|
|
let control = c["controlType"] as? String ?? ""
|
|
let mode = control == "rotary" ? "Relative signed" : control == "rotary-64" ? "Relative offset" : "Absolute"
|
|
if mode != "Absolute", !["Browse","A Jog","B Jog"].contains(action) {
|
|
blocked.insert(key);issues.insert("\(path): relative control is unsupported for this action.");continue
|
|
}
|
|
bindings.append(.init(status:status,number:number,action:action,mode:mode,flipped:c["flipped"] as? Bool ?? false,pickup:c["pickupMode"] as? Bool ?? false))
|
|
if c["output"] != nil { issues.insert("\(path): XML LED/display output is not implemented; input is supported where listed.") }
|
|
}
|
|
let grouped = Dictionary(grouping:bindings,by:{$0.key})
|
|
for (key,group) in grouped where Set(group.map(\.action)).count > 1 || Set(group.map(\.mode)).count > 1 || Set(group.map(\.flipped)).count > 1 {
|
|
blocked.insert(key);issues.insert("MIDI \(key): conflicting assignments disabled.")
|
|
}
|
|
bindings = grouped.keys.sorted().compactMap { blocked.contains($0) ? nil : grouped[$0]?.first }
|
|
if !blocked.isEmpty { issues.insert("All bindings sharing an unsupported/conditional address are disabled to prevent accidental actions.") }
|
|
if root["customClassName"] != nil { issues.insert("Device-specific djay custom processor is unavailable; only independent standard MIDI bindings are enabled.") }
|
|
if root["outputs"] != nil { issues.insert("Device output/display definitions are not executed.") }
|
|
return .init(id:root["uuid"] as? String ?? filename,name:filename,endpoint:root["endpointName"] as? String ?? filename,bindings:bindings,issues:issues.sorted(),total:controls.filter{$0["headingTitle"] == nil}.count)
|
|
}
|
|
static func failure(_ message: String) -> NSError { NSError(domain:"DJ XML Mapping",code:1,userInfo:[NSLocalizedDescriptionKey:message]) }
|
|
static func normalized(_ name: String) -> String {
|
|
name.lowercased().split(whereSeparator:{$0.isWhitespace}).joined(separator:" ")
|
|
}
|
|
static func match(_ endpoint: String,in profiles: [DJXMLProfile]) -> DJXMLProfile? {
|
|
let matches = profiles.filter { !$0.bindings.isEmpty && normalized($0.endpoint) == normalized(endpoint) }
|
|
return matches.count == 1 ? matches.first : nil
|
|
}
|
|
static func action(_ path: String) -> String? {
|
|
let global = ["mixer.crossfade":"Crossfader","mixer.masterLevel":"Master","mixer.monitorLevel":"Cue volume","musicLibrary.libraryRotary":"Browse","musicLibrary.load1":"A Load","musicLibrary.load2":"B Load","musicLibrary.previewSelectedSong":"Preview selected","mixer.lineVolume1":"A Level","mixer.lineVolume2":"B Level","mixer.monitorActive1":"A Headphones","mixer.monitorActive2":"B Headphones"]
|
|
if let action = global[path] { return action }
|
|
let pieces = path.split(separator:".").map(String.init)
|
|
guard pieces.count == 2,let deck = ["turntable1":"A","turntable2":"B"][pieces[0]] else { return nil }
|
|
let commands = ["playPause":"Play","cuePositionOrJumpConsideringPlayState1":"Cue button","cuePosition1":"Set cue","jumpToCueAndPause1":"Cue","jumpToCueAndPlay1":"Cue play","bpmSync":"Sync","highEQ":"High","midEQ":"Mid","lowEQ":"Low","filter":"Filter","gain":"Gain","speed":"Tempo XML","jogSeekMove":"Jog","scratchingNoTouchMove":"Jog","autoLoopOnOff":"Loop","loopIn":"Loop in","loopOut":"Loop out","autoLoopDurationDouble":"Loop double","autoLoopDurationHalf":"Loop half"]
|
|
if let action = commands[pieces[1]] { return deck+" "+action }
|
|
for n in 1...4 {
|
|
if pieces[1] == "cueOrJumpIfAlreadySet\(n)" { return deck+" Hot cue \(n)" }
|
|
if pieces[1] == "clearCuePoint\(n)" { return deck+" Clear cue \(n)" }
|
|
}
|
|
for n in [1,2,4,8,16,32] where pieces[1] == "autoLoop\(n)BeatInterval" { return deck+" Loop \(n)" }
|
|
return nil
|
|
}
|
|
static let bundled: [DJXMLProfile] = {
|
|
guard let folder = Bundle.main.resourceURL?.appendingPathComponent("MIDIMappings"),let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:nil) else { return [] }
|
|
return files.filter{$0.pathExtension == "xml"}.compactMap { url in
|
|
guard let data = try? Data(contentsOf:url) else { return nil }
|
|
return try? parse(data,filename:url.deletingPathExtension().lastPathComponent)
|
|
}.sorted{$0.name.localizedStandardCompare($1.name) == .orderedAscending}
|
|
}()
|
|
}
|
|
|
|
@MainActor extension DJMIDI {
|
|
func importXMLFiles() {
|
|
let panel = NSOpenPanel();panel.title = "Import djay XML MIDI Mappings";panel.allowsMultipleSelection = true
|
|
panel.allowedContentTypes = [.xml,.propertyList]+[UTType(filenameExtension:"djayMidiMapping")].compactMap{$0}
|
|
guard panel.runModal() == .OK else { return }
|
|
do {
|
|
let imported = try panel.urls.map { url -> DJXMLProfile in
|
|
let scoped = url.startAccessingSecurityScopedResource();defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
|
return try DJXMLProfile.parse(Data(contentsOf:url),filename:url.deletingPathExtension().lastPathComponent)
|
|
}
|
|
addXMLProfiles(imported)
|
|
} catch { status = "Mapping import failed: \(error.localizedDescription)" }
|
|
}
|
|
}
|