155 lines
11 KiB
Swift
155 lines
11 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 DJXMLOutput: Codable {
|
||
var key: String
|
||
var action: String
|
||
var status: UInt8
|
||
var number: UInt8
|
||
var on: UInt8
|
||
var off: UInt8
|
||
var flipped: Bool
|
||
func packet(active: Bool) -> [UInt8] { [status,number,(active != flipped) ? on : off] }
|
||
static func parse(_ output: [String:Any],binding: DJXMLBinding) -> Self? {
|
||
let supported = ["A Play","B Play","A Cue button","B Cue button","A Sync","B Sync","A Headphones","B Headphones","A Loop","B Loop"] + ["A","B"].flatMap { d in (1...4).map{d+" Hot cue \($0)"} }
|
||
guard supported.contains(binding.action),binding.status & 0xF0 == 0x90 else { return nil }
|
||
let channel = output["midiChannel"] as? Int ?? Int(binding.status & 15)
|
||
let number = output["midiData"] as? Int ?? Int(binding.number)
|
||
let type = output["midiMessageType"] as? Int ?? 1
|
||
guard (0...15).contains(channel),(0...127).contains(number),[0,1,3].contains(type) else { return nil }
|
||
func byte(_ key: String,_ fallback: Int) -> UInt8 { UInt8(min(127,max(0,(output[key] as? NSNumber)?.intValue ?? fallback))) }
|
||
return .init(key:binding.key,action:binding.action,status:UInt8((type == 0 ? 0x80 : type == 1 ? 0x90 : 0xB0)+channel),number:UInt8(number),on:byte("midiMaxValue",127),off:byte("midiDisabledValue",Int(byte("midiMinValue",0))),flipped:output["flipped"] as? Bool ?? false)
|
||
}
|
||
}
|
||
|
||
struct DJXMLProfile: Codable, Identifiable {
|
||
var id: String
|
||
var name: String
|
||
var endpoint: String
|
||
var bindings: [DJXMLBinding]
|
||
var issues: [String]
|
||
var total: Int
|
||
var aliases: [String]?
|
||
var adapter: String?
|
||
var outputs: [DJXMLOutput]?
|
||
var report: String {
|
||
"\(name)\n\(bindings.count) supported bindings of \(total) control entries.\n\n" +
|
||
(adapter == "Mixtrack Go" ? "Uses the dedicated Mixtrack Go adapter for 14-bit tempo, relative jog, pad modes and transport/mode feedback.\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 || root["schemeVersion"] as? String == "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](),outputs = [DJXMLOutput](),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 let output = c["output"] as? [String:Any] {
|
||
if let feedback = DJXMLOutput.parse(output,binding:bindings.last!) { outputs.append(feedback) }
|
||
else { issues.insert("\(path): this XML output definition is not supported.") }
|
||
}
|
||
}
|
||
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 }
|
||
outputs.removeAll { blocked.contains($0.key) }
|
||
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.") }
|
||
let aliases = root["endpointName"] as? [String] ?? [root["endpointName"] as? String ?? filename]
|
||
let adapter = root["customClassName"] as? String == "NumarkMixtrackGo" ? "Mixtrack Go" : nil
|
||
if adapter != nil {
|
||
bindings = MixtrackGoProtocol.mappings(source:0).map { .init(status:$0.status,number:$0.number,action:$0.action,mode:$0.mode,flipped:false,pickup:false) }
|
||
issues.insert("Mixtrack Go uses KJMix Pro’s dedicated protocol adapter, including its pad-mode state. Stems, sampler and FX modes remain unavailable.")
|
||
}
|
||
return .init(id:root["uuid"] as? String ?? filename,name:filename,endpoint:aliases.first ?? filename,bindings:bindings,issues:issues.sorted(),total:controls.filter{$0["headingTitle"] == nil}.count,aliases:aliases,adapter:adapter,outputs:outputs)
|
||
}
|
||
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 && ($0.aliases ?? [$0.endpoint]).contains { normalized($0) == 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)" }
|
||
}
|
||
}
|