broken changes

This commit is contained in:
Mark Gregory
2026-11-05 16:43:08 +00:00
parent 1b5e48dfa1
commit 13a6a9e1c8
6 changed files with 173 additions and 26 deletions
+1 -1
View File
@@ -663,7 +663,7 @@ struct StudioSettingsDialog: View {
else if category == "Devices" { KaraokeDevicesPreferences(model:model) }
else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) }
Divider()
HStack { Text(scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Use Apply to save device changes" : scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onChange(of:category) { _ in model.dj.midi.learning = nil }
+47 -10
View File
@@ -430,6 +430,8 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
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 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."
@@ -483,6 +485,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
}
}
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) {
@@ -494,8 +497,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
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() }
}
if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) }
} else if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) }
}
else { status = "Could not connect MIDI source (\(result))" }
}
@@ -541,11 +543,11 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
let preset = MixtrackGoProtocol.mappings(source:sourceID)
let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset
profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");mixtrack = .init();save()
sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7])
initializeMixtrack()
status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pad modes. Hardware testing required."
onAction?("Controller feedback",0,false)
}
func useCustomProfile() { profiles[String(sourceID)] = "Custom";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" }
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 }
@@ -565,22 +567,56 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
if profile.adapter == "Mixtrack Go" {
mixtrack = .init();let preset = MixtrackGoProtocol.mappings(source:sourceID)
let ids = Set(preset.map(\.id));mappings.removeAll { ids.contains($0.id) };mappings += preset;save()
sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]);onAction?("Controller feedback",0,false)
initializeMixtrack()
}
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() || (candidate.contains("mixtrack") && candidate.contains("go")) { outputDestination = endpoint;break }
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 mixtrackEnabled else { return }
sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop))
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 }
@@ -593,7 +629,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
}
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 { if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } }
deinit { initializationTask?.cancel();if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } }
}
private enum DJStyle {
@@ -1027,7 +1063,7 @@ struct DJContinuousConsole: View {
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)
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)
@@ -1457,6 +1493,7 @@ struct MixtrackGoProtocol {
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
+29 -3
View File
@@ -20,6 +20,27 @@ struct DJXMLBinding: Codable, Equatable {
}
}
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
@@ -29,6 +50,7 @@ struct DJXMLProfile: Codable, Identifiable {
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" : "") +
@@ -40,7 +62,7 @@ struct DJXMLProfile: Codable, Identifiable {
(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](),issues = Set<String>(),blocked = Set<String>()
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))
@@ -63,13 +85,17 @@ struct DJXMLProfile: Codable, Identifiable {
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.") }
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.") }
@@ -79,7 +105,7 @@ struct DJXMLProfile: Codable, Identifiable {
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)
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 {