Files
2026-11-05 16:43:08 +00:00

155 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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)" }
}
}