broken changes

This commit is contained in:
Mark Gregory
2026-11-05 16:26:24 +00:00
parent eb98f97ded
commit 7bf251c5e6
215 changed files with 822178 additions and 26 deletions
+119 -7
View File
@@ -323,7 +323,14 @@ struct DJAudioDevice: Identifiable, Equatable {
case "Cue button": if p.playing { p.returnToCue() } else { p.setCue() }
case "Cue play": p.returnToCue();p.toggle()
case "Loop off": p.clearLoop()
case "Loop 1","Loop 2","Loop 4","Loop 8": d.loopBeats = Int(parts[1].split(separator:" ").last!) ?? 8;toggleLoop(d)
case "Loop in": p.markLoopIn()
case "Loop out": p.markLoopOut()
case "Loop double": d.loopBeats = min(32,d.loopBeats*2)
case "Loop half": d.loopBeats = max(1,d.loopBeats/2)
case "Tempo XML":
let rate = 1+(value*2-1)*settings.tempoRange/100
if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate }
case "Loop 1","Loop 2","Loop 4","Loop 8","Loop 16","Loop 32": d.loopBeats = Int(parts[1].split(separator:" ").last!) ?? 8;toggleLoop(d)
case "Clear cue 1","Clear cue 2","Clear cue 3","Clear cue 4": p.hotCue((Int(parts[1].suffix(1)) ?? 1)-1,clear:true)
case "Sync": sync(d)
case "Loop": toggleLoop(d)
@@ -360,7 +367,7 @@ struct DJMIDIDecoder {
guard status != 0 else { continue };data.append(byte)
let count = [0xC0,0xD0].contains(status & 0xF0) ? 1 : 2
if data.count == count {
if [0x80,0x90,0xB0].contains(status & 0xF0) { result.append(.init(status:status,data1:data[0],data2:data.count > 1 ? data[1] : 0)) }
if [0x80,0x90,0xB0,0xE0].contains(status & 0xF0) { result.append(.init(status:status,data1:data[0],data2:data.count > 1 ? data[1] : 0)) }
data = []
}
}
@@ -382,6 +389,12 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
@Published private(set) var sourceID: Int32 = 0
@Published var mappings = [DJMIDIMapping]()
@Published private(set) var profiles = [String:String]()
@Published var xmlCatalog = DJXMLProfile.bundled
@Published private(set) var xmlAssignments = [String:DJXMLProfile]()
@Published var autoDetect = true { didSet { defaults.set(autoDetect,forKey:"DJ.MIDIAutoDetect.v1");if autoDetect { refreshSources() } } }
private var pickupValues = [String:Double](),pickedUp = Set<String>()
var currentValue: ((String) -> Double?)?
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)
@Published var learning: String?
@@ -393,6 +406,9 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard,connect: Bool = true) {
self.defaults = defaults
autoDetect = defaults.object(forKey:"DJ.MIDIAutoDetect.v1") as? Bool ?? true
if let data = defaults.data(forKey:"DJ.XMLAssignments.v1"),let saved = try? JSONDecoder().decode([String:DJXMLProfile].self,from:data) { xmlAssignments = saved }
if let data = defaults.data(forKey:"DJ.XMLCatalog.v1"),let saved = try? JSONDecoder().decode([DJXMLProfile].self,from:data) { mergeXML(saved) }
profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:]
if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved }
guard connect else { return }
@@ -426,21 +442,45 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
}
if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber,
sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) }
if connected == 0,autoDetect,defaults.object(forKey:"DJ.MIDISource.v1") == nil {
let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil || DJXMLProfile.normalized(source.name) == "numark mixtrack go" }
if matches.count == 1,let match = matches.first { select(match.id) }
else if matches.count > 1 { status = "Multiple supported controllers detected · choose a MIDI input" }
}
}
private func disconnect() {
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = []
}
func select(_ id: Int32) {
disconnect()
if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");return }
if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");autoDetect = false;return }
guard let source = sources.first(where:{$0.id == id}) else { return }
let result = MIDIPortConnectSource(port,source.endpoint,nil)
if result == noErr { connected = source.endpoint;sourceID = id;defaults.set(Int(id),forKey:"DJ.MIDISource.v1");status = "Connected: \(source.name)";mixtrack = .init();selectFeedbackOutput(source.name) }
if result == noErr { connected = source.endpoint;sourceID = id;defaults.set(Int(id),forKey:"DJ.MIDISource.v1");status = "Connected: \(source.name)";mixtrack = .init();selectFeedbackOutput(source.name)
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() }
}
}
else { status = "Could not connect MIDI source (\(result))" }
}
func receive(_ bytes: [UInt8]) {
for message in decoder.feed(bytes) {
let type = message.status & 0xF0
if let profile = activeXML,learning == nil {
if type == 0x80 || (type == 0x90 && message.data2 == 0) { continue }
for binding in profile.bindings where binding.matches(message) {
let value = binding.value(message)
if binding.pickup,binding.mode == "Absolute",let target = currentValue?(binding.action),!pickedUp.contains(binding.key) {
let previous = pickupValues[binding.key];pickupValues[binding.key] = value
guard abs(value-target) < 0.025 || previous.map({ ($0-target)*(value-target) <= 0 }) == true else { continue }
pickedUp.insert(binding.key)
}
if binding.mode != "Absolute",value == 0 { continue }
onAction?(binding.action,value,binding.mode != "Absolute")
}
continue
}
guard type == 0xB0 || (type == 0x90 && message.data2 > 0) else { continue }
if let action = learning {
let mapping = DJMIDIMapping(source:sourceID,status:message.status,number:message.data1,action:action,mode:action.hasSuffix(" Jog") ? learnMode : "Absolute")
@@ -470,7 +510,25 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
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.removeValue(forKey:String(sourceID));defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" }
func useCustomProfile() { 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 }
}
func addXMLProfiles(_ imported: [DJXMLProfile]) {
mergeXML(imported)
if let data = try? JSONEncoder().encode(xmlCatalog) { defaults.set(data,forKey:"DJ.XMLCatalog.v1") }
status = "Imported \(imported.count) XML profiles · select one to review and apply"
if autoDetect { refreshSources() }
}
func applyXML(_ profile: DJXMLProfile) {
guard !profile.bindings.isEmpty else { status = "This profile has no supported standalone MIDI controls · see compatibility report";return }
xmlAssignments[String(sourceID)] = profile;profiles[String(sourceID)] = "XML:"+profile.id
defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1")
if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") }
pickupValues = [:];pickedUp = [];learning = nil
status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report"
}
private func selectFeedbackOutput(_ name: String) {
outputDestination = 0
for i in 0..<MIDIGetNumberOfDestinations() {
@@ -512,6 +570,7 @@ private enum DJStyle {
struct DJConsole: View {
@ObservedObject var model: AppModel
@ObservedObject var system: DJSystem
var expanded = false
@State private var settingsModal = false
@State private var routing = false
@State private var mapping = false
@@ -536,7 +595,7 @@ struct DJConsole: View {
VStack(spacing:10) {
header
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")})
browser.frame(height:130)
browser.frame(height:expanded ? 300 : 130)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
@@ -635,9 +694,28 @@ struct DJConsole: View {
private func loadSelected(_ deck: String) { if let selectedSong { system.load(selectedSong,into:deck) } else { system.status = "Select a DJ library track, then Load \(deck), or drag a DJ library track onto the deck." } }
private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [] }
private func connectMIDI() {
system.midi.currentValue = { [weak system] action in
guard let system else { return nil }
if action == "Crossfader" { return system.crossfader }
if action == "Master" { return system.master }
if action == "Cue volume" { return system.cueLevel }
let parts = action.split(separator:" ",maxSplits:1).map(String.init)
guard parts.count == 2 else { return nil };let d = system.channel(parts[0]),p = d.player
switch parts[1] {
case "Tempo XML": return (p.tempo-1)/(2*system.settings.tempoRange/100)+0.5
case "High": return (p.highGain+12)/24
case "Mid": return (p.midGain+12)/24
case "Low": return (p.lowGain+12)/24
case "Filter": return (p.filter+1)/2
case "Gain": return (d.gain+12)/24
case "Level": return d.level
default: return nil
}
}
system.midi.onAction = { [weak system] name,value,relative in
guard let system else { return }
if name == "Browse" {
guard value != 0 else { return }
let rows = songs;guard !rows.isEmpty else { return }
let current = rows.firstIndex(where:{$0.id == selected}) ?? (value > 0 ? -1 : rows.count)
let index = min(rows.count-1,max(0,current+(value >= 0 ? 1 : -1)))
@@ -765,6 +843,11 @@ private struct DJMappingSheet: View {
@ObservedObject var midi: DJMIDI
var embedded = false
@State private var action = "A Play"
@State private var xmlSelection = ""
@State private var xmlSearch = ""
@State private var report: DJXMLProfile?
private var selectedXML: DJXMLProfile? { midi.xmlCatalog.first { $0.id == xmlSelection } }
private var filteredXML: [DJXMLProfile] { midi.xmlCatalog.filter { xmlSearch.isEmpty || $0.name.localizedCaseInsensitiveContains(xmlSearch) || $0.id == xmlSelection } }
@Environment(\.dismiss) private var dismiss
private let commands = ["Play","Stop","Cue","Set cue","Sync","Loop","Headphones","Load","Gain","High","Mid","Low","Filter","Level","Tempo","Jog","Hot cue 1","Hot cue 2","Hot cue 3","Hot cue 4"]
private var actions: [String] { ["Crossfader","Master"]+["A","B"].flatMap{deck in commands.map{deck+" "+$0}} }
@@ -773,6 +856,21 @@ private struct DJMappingSheet: View {
HStack { Text("MIDI Controller Mapping").font(.title2.bold());Spacer();Button("Refresh") { midi.refreshSources() } }
Picker("MIDI input",selection:Binding(get:{midi.sourceID},set:{midi.select($0)})) { Text("Not connected").tag(Int32(0));ForEach(midi.sources) { Text($0.name).tag($0.id) } }
HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green);Button("Use Custom Learn") { midi.useCustomProfile() } } }
Toggle("Automatically detect and reconnect supported controllers",isOn:$midi.autoDetect)
VStack(alignment:.leading,spacing:8) {
HStack { Text("XML Controller Profiles").font(.headline);Spacer();Button("Import XML…") { midi.importXMLFiles() } }
TextField("Find a controller",text:$xmlSearch).textFieldStyle(.roundedBorder)
Picker("Profile",selection:$xmlSelection) {
Text("Choose a profile").tag("")
ForEach(filteredXML) { Text($0.name).tag($0.id) }
}
HStack {
Button("Apply XML Profile") { if let selectedXML { midi.applyXML(selectedXML) } }.disabled(midi.sourceID == 0 || selectedXML?.bindings.isEmpty != false)
Button("Compatibility Report") { report = selectedXML }.disabled(selectedXML == nil)
Button("Use Custom Learn") { midi.useCustomProfile() }
}
if let active = midi.activeXML { Text("Active: \(active.name) · \(active.bindings.count) supported controls").font(.caption).foregroundStyle(.green) }
}
Text("Mixtrack Go preset uses the supplied Mixxx protocol mapping, including 14-bit tempo and hot-cue/loop pad modes. Other controllers can use MIDI Learn.").font(.caption).foregroundStyle(.secondary)
HStack {
Picker("Action",selection:$action) { ForEach(actions,id:\.self) { Text($0).tag($0) } }
@@ -784,6 +882,11 @@ private struct DJMappingSheet: View {
}
Text(midi.status).font(.caption)
List {
if let active = midi.activeXML {
ForEach(Array(active.bindings.enumerated()),id:\.offset) { _,binding in
HStack { Text(binding.action);Spacer();Text("Ch \((binding.status & 15)+1) · \(binding.number) · \(binding.mode)").foregroundStyle(.secondary) }
}
} else {
ForEach(midi.mappings.filter{$0.source == midi.sourceID}) { map in
HStack {
Text(map.action).frame(width:110,alignment:.leading)
@@ -792,10 +895,19 @@ private struct DJMappingSheet: View {
Button { midi.remove(map.id) } label: { Image(systemName:"trash") }.buttonStyle(.borderless).accessibilityLabel("Remove mapping for \(map.action)")
}
}
}
}.frame(height:200)
Text("Mappings save per MIDI source ID. Custom learn supports note buttons and 7-bit CCs; the Mixtrack Go preset also handles its 14-bit tempo. Use relative mode only for Jog encoders. Load uses the selected DJ library row. Button releases are ignored. The Mixtrack Go preset sends transport/sync/mode LED feedback when a matching output is found. Stems, sampler, FX modes and vinyl scratching are not implemented.").font(.caption).foregroundStyle(.secondary)
if !embedded { HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } }
}.padding(24).frame(width:660)
.onAppear { if let active = midi.activeXML { xmlSelection = active.id } }
.sheet(item:$report) { profile in
VStack(alignment:.leading,spacing:16) {
Text("Mapping Compatibility").font(.title2.bold())
ScrollView { Text(profile.report).font(.system(size:12)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }
HStack { Spacer();Button("Done") { report = nil }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:700,height:540)
}
}
}