Files
KMix_Pro/KJamsClone/DJXMLMapping.swift
T
2026-11-05 16:26:24 +00:00

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)" }
}
}