broken changes

This commit is contained in:
Mark Gregory
2026-11-05 16:35:24 +00:00
parent 7bf251c5e6
commit 1b5e48dfa1
7 changed files with 225 additions and 44 deletions
+4
View File
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
B30000000000000000000001 /* DJAudioRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002; };
B20000000000000000000001 /* DJXMLMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002; };
B20000000000000000000003 /* MIDIMappings in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004; };
B10000000000000000000001 /* DJWorkstation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002; };
@@ -36,6 +37,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = "<group>"; };
B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = "<group>"; };
B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = "<group>"; };
B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = "<group>"; };
@@ -73,6 +75,7 @@
000000000000000000000001 /* KJMixProApp.swift */,
B10000000000000000000002 /* DJWorkstation.swift */,
B20000000000000000000002 /* DJXMLMapping.swift */,
B30000000000000000000002 /* DJAudioRouting.swift */,
B20000000000000000000004 /* MIDIMappings */,
000000000000000000000002 /* Library.swift */,
000000000000000000000003 /* CDG.swift */,
@@ -196,6 +199,7 @@
000000000000000000000065 /* KJMixProApp.swift in Sources */,
B10000000000000000000001 /* DJWorkstation.swift in Sources */,
B20000000000000000000001 /* DJXMLMapping.swift in Sources */,
B30000000000000000000001 /* DJAudioRouting.swift in Sources */,
000000000000000000000066 /* Library.swift in Sources */,
000000000000000000000067 /* CDG.swift in Sources */,
000000000000000000000068 /* Importer.swift in Sources */,
+89
View File
@@ -0,0 +1,89 @@
import SwiftUI
import CoreAudio
struct DJAudioRoute: Codable, Equatable {
var uid = ""
var firstChannel = 0
}
struct DJAudioRouting: Codable, Equatable {
var mode = "Internal"
var main = DJAudioRoute()
var deckA = DJAudioRoute()
var deckB = DJAudioRoute()
var cue = DJAudioRoute()
func route(_ letter: String) -> DJAudioRoute { mode == "External" ? (letter == "A" ? deckA : deckB) : main }
static func channelMap(channels: Int,first: Int) throws -> [Int32] {
guard channels >= 2,first >= 0,first % 2 == 0,first+1 < channels else { throw LibraryError.message("Choose an available stereo channel pair.") }
var map = [Int32](repeating:-1,count:channels);map[first] = 0;map[first+1] = 1;return map
}
func validate(devices: [DJAudioDevice],defaultID: AudioDeviceID?) throws {
func resolved(_ route: DJAudioRoute) throws -> DJAudioDevice {
guard let device = devices.first(where:{route.uid.isEmpty ? $0.id == defaultID : $0.uid == route.uid}) else { throw LibraryError.message("An audio output is unavailable. Reconnect it or choose another device.") }
_ = try Self.channelMap(channels:device.outputChannels,first:route.firstChannel);return device
}
let outputs = mode == "External" ? [deckA,deckB] : [main]
for route in outputs { _ = try resolved(route) }
if mode == "External",try resolved(deckA).id == resolved(deckB).id,deckA.firstChannel == deckB.firstChannel {
throw LibraryError.message("External mixer decks need separate output devices or channel pairs.")
}
if !cue.uid.isEmpty {
let cueDevice = try resolved(cue)
for route in outputs where try resolved(route).id == cueDevice.id && route.firstChannel == cue.firstChannel {
throw LibraryError.message("Pre-cueing overlaps a public output. Choose another channel pair or device.")
}
}
}
}
@MainActor final class DJAudioDeviceMonitor {
private var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
private var block: AudioObjectPropertyListenerBlock?
init(_ changed: @escaping @MainActor () -> Void) {
let listener: AudioObjectPropertyListenerBlock = { _,_ in Task { @MainActor in changed() } }
block = listener;AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject),&address,.main,listener)
}
deinit { if let block { AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject),&address,.main,block) } }
}
struct DJAudioDevicesPane: View {
@ObservedObject var system: DJSystem
@State private var draft = DJAudioRouting()
var body: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Audio Routing") {
VStack(spacing:22) {
HStack { Text("Mixer Mode:").frame(width:130,alignment:.trailing);Picker("Mixer Mode",selection:$draft.mode) { Text("Internal").tag("Internal");Text("External").tag("External") }.pickerStyle(.radioGroup).horizontalRadioGroupLayout().labelsHidden();Spacer() }
if draft.mode == "Internal" { row("Main Output",route:$draft.main,icon:"hifispeaker.fill") }
else { row("Deck A",route:$draft.deckA,icon:"a.circle");row("Deck B",route:$draft.deckB,icon:"b.circle") }
row("Pre-Cueing",route:$draft.cue,icon:"headphones",optional:true)
HStack { Text("Cue Volume:").frame(width:130,alignment:.trailing);Slider(value:$system.cueLevel,in:0...1).frame(maxWidth:320);Spacer() }
}.padding(.vertical,22)
}
Text(draft.mode == "Internal" ? "Internal mixes both decks through the main output. Pre-cueing can use channels 3–4 on the same four-channel sound card." : "External sends each deck to its own stereo pair. The software crossfader is bypassed; mix the outputs on your external mixer.").font(.callout).foregroundStyle(.secondary)
if system.devices.isEmpty { Text("No audio outputs reported by macOS. Reconnect the interface and refresh.").foregroundStyle(.orange) }
HStack {
Button("Refresh Devices") { system.refreshDevices() }
Spacer()
Button("Revert") { draft = system.routing }.disabled(draft == system.routing)
Button("Apply") { system.applyRouting(draft) }.buttonStyle(.borderedProminent).disabled(draft == system.routing)
}
Text("Booth, microphone and recording input are not available. Karaoke has its own output settings.").font(.caption).foregroundStyle(.secondary)
}.onAppear { system.refreshDevices();draft = system.routing }
}
private func row(_ label: String,route: Binding<DJAudioRoute>,icon: String,optional: Bool = false) -> some View {
let device = system.devices.first { route.wrappedValue.uid.isEmpty ? $0.id == DJAudioDevice.defaultOutput() : $0.uid == route.wrappedValue.uid }
let pairs = Array(stride(from:0,to:max(0,(device?.outputChannels ?? 2)-1),by:2))
return HStack(spacing:12) {
Text(label+":").frame(width:130,alignment:.trailing)
Picker(label,selection:Binding(get:{route.wrappedValue.uid},set:{route.wrappedValue = .init(uid:$0,firstChannel:0)})) {
Text(optional ? "None" : "System default").tag("")
ForEach(system.devices) { Text($0.name).tag($0.uid) }
if !route.wrappedValue.uid.isEmpty,device == nil { Text("Disconnected device").tag(route.wrappedValue.uid) }
}.labelsHidden().frame(maxWidth:340)
Image(systemName:icon).foregroundStyle(.secondary).frame(width:24)
Picker("\(label) channels",selection:route.firstChannel) {
ForEach(pairs,id:\.self) { Text("ch \($0+1)–\($0+2)").tag($0) }
}.labelsHidden().frame(width:110).disabled(optional && route.wrappedValue.uid.isEmpty)
}
}
}
+71 -36
View File
@@ -106,6 +106,15 @@ struct DJAudioDevice: Identifiable, Equatable {
var id: AudioDeviceID
var uid: String
var name: String
var outputChannels = 2
static func channelCount(_ id: AudioDeviceID) -> Int {
var address = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreamConfiguration,mScope:kAudioDevicePropertyScopeOutput,mElement:kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(id,&address,0,nil,&size) == noErr,size >= MemoryLayout<AudioBufferList>.size else { return 0 }
let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout<AudioBufferList>.alignment);defer { memory.deallocate() }
guard AudioObjectGetPropertyData(id,&address,0,nil,&size,memory) == noErr else { return 0 }
return UnsafeMutableAudioBufferListPointer(memory.assumingMemoryBound(to:AudioBufferList.self)).reduce(0) { $0+Int($1.mNumberChannels) }
}
static func outputs() -> [Self] {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var size: UInt32 = 0
@@ -123,7 +132,7 @@ struct DJAudioDevice: Identifiable, Equatable {
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
return text?.takeRetainedValue() as String?
}
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output")
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channelCount(id))
}
}
static func defaultOutput() -> AudioDeviceID? {
@@ -152,6 +161,8 @@ struct DJAudioDevice: Identifiable, Equatable {
@Published var devices = [DJAudioDevice]()
@Published var masterUID = ""
@Published var cueUID = ""
@Published private(set) var routing = DJAudioRouting()
private var audioDeviceMonitor: DJAudioDeviceMonitor?
@Published var settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } }
@Published var status = "Load music into A or B. Karaoke keeps its own mixer and output."
@Published var error: String?
@@ -172,12 +183,17 @@ struct DJAudioDevice: Identifiable, Equatable {
Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).sink { [weak self] _ in
Task { @MainActor [weak self] in self?.updateControllerFeedback() }
}.store(in:&syncObservers)
Publishers.CombineLatest4(a.player.$hotCues,b.player.$hotCues,a.player.$loopEnabled,b.player.$loopEnabled).sink { [weak self] _ in
Task { @MainActor [weak self] in self?.updateControllerFeedback() }
}.store(in:&syncObservers)
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized }
if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) }
applyMix()
}
audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() }
}
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
@@ -216,7 +232,7 @@ struct DJAudioDevice: Identifiable, Equatable {
deinit { importTask?.cancel() }
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = weights.0;b.crossWeight = weights.1 }
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b }
func load(_ song: Song,into letter: String,overridePlaying: Bool = false) {
perform {
@@ -225,8 +241,9 @@ struct DJAudioDevice: Identifiable, Equatable {
if syncActive { disableSync() }
if previewDeck == letter { stopPreview() }
try deck.load(song)
if !masterUID.isEmpty {
do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error }
let route = routing.route(letter)
if !route.uid.isEmpty || route.firstChannel != 0 || routing.mode == "External" {
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error }
}
if settings.resetEQOnLoad { deck.player.resetTone() }
if settings.autoPlayOnLoad { deck.player.toggle() }
@@ -240,21 +257,35 @@ struct DJAudioDevice: Identifiable, Equatable {
return output.id
}
func routeMaster(_ uid: String) {
let old = masterUID
var next = routing;next.main.uid = uid;next.main.firstChannel = 0;applyRouting(next)
}
func applyRouting(_ next: DJAudioRouting) {
let previous = routing
perform {
let id = try device(uid)
try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
stopPreview()
do { try a.player.routeOutput(id);try b.player.routeOutput(id);masterUID = uid }
catch { if let prior = try? device(old) { try? a.player.routeOutput(prior);try? b.player.routeOutput(prior) };throw error }
do {
for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) }
try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic)
routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix()
status = "Audio routing applied · \(next.mode) mixer"
} catch {
for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } }
throw error
}
}
}
private func validateCue() throws {
var config = routing;config.cue.uid = cueUID
try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
}
func previewLibrary(_ song: Song) {
perform {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") }
let output = try device(cueUID)
guard output != (try device(masterUID)) else { throw LibraryError.message("Library preview needs a separate headphone device.") }
try validateCue()
stopPreview();var audio = song;audio.cdg = nil
try preview.load(audio,startPlaying:false);try preview.routeOutput(output);preview.toggle();previewDeck = "Library"
try preview.load(audio,startPlaying:false);try preview.routeOutput(output,firstChannel:routing.cue.firstChannel);preview.toggle();previewDeck = "Library"
}
}
func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() }
@@ -264,10 +295,10 @@ struct DJAudioDevice: Identifiable, Equatable {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a separate headphone output in Audio Routing first.") }
guard let song = deck.song else { throw LibraryError.message("Load a track first.") }
let output = try device(cueUID)
guard output != (try device(masterUID)) else { throw LibraryError.message("Headphone preview requires a different output device from the master. Multichannel pairs on one device are not yet supported.") }
try validateCue()
stopPreview();var audio = song;audio.cdg = nil
try preview.load(audio,startPlaying:false)
try preview.routeOutput(output)
try preview.routeOutput(output,firstChannel:routing.cue.firstChannel)
preview.tempo = deck.player.tempo;preview.lowGain = deck.player.lowGain;preview.midGain = deck.player.midGain;preview.highGain = deck.player.highGain;preview.filter = deck.player.filter
preview.seek(deck.player.time);preview.toggle();previewDeck = deck.letter;updateControllerFeedback()
status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position"
@@ -302,7 +333,7 @@ struct DJAudioDevice: Identifiable, Equatable {
}
func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true }
func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false }
private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck) }
private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck,aCues:a.player.hotCues.map{$0 != nil},bCues:b.player.hotCues.map{$0 != nil},aLoop:a.player.loopEnabled,bLoop:b.player.loopEnabled) }
func hotCue(_ index: Int,deck: DJChannel) {
let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil
deck.player.hotCue(index)
@@ -325,8 +356,10 @@ struct DJAudioDevice: Identifiable, Equatable {
case "Loop off": p.clearLoop()
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 "Loop double","Loop half":
let factor = parts[1] == "Loop double" ? 2.0 : 0.5
d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor)))
if p.loopEnabled,let start = p.loopIn,let end = p.loopOut { p.defineLoop(start:start,end:min(p.duration,start+(end-start)*factor)) }
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 }
@@ -404,11 +437,12 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
private var client = MIDIClientRef(0),port = MIDIPortRef(0),connected = MIDIEndpointRef(0)
private var decoder = DJMIDIDecoder()
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard,connect: Bool = true) {
self.defaults = defaults
init(defaults: UserDefaults = .standard,connect: Bool = true,catalog: [DJXMLProfile] = DJXMLProfile.bundled) {
self.defaults = defaults;self.xmlCatalog = catalog
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) }
for (source,profile) in xmlAssignments { if let updated = xmlCatalog.first(where:{$0.id == profile.id}) { xmlAssignments[source] = updated } }
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 }
@@ -443,7 +477,7 @@ 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" }
let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil }
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" }
}
@@ -461,13 +495,14 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
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 { 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 let profile = activeXML,profile.adapter == nil,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)
@@ -487,7 +522,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
mappings.removeAll{$0.id == mapping.id || ($0.source == sourceID && $0.action == action)};mappings.append(mapping);save();learning = nil;status = "Learned \(action) · channel \((message.status & 0x0F)+1), control \(message.data1)"
continue
}
if profiles[String(sourceID)] == "Mixtrack Go",let events = mixtrack.consume(message) {
if mixtrackEnabled,let events = mixtrack.consume(message) {
for event in events {
if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) }
else { onAction?(event.action,event.value,event.relative) }
@@ -501,7 +536,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute")
}
}
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" }
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" }
func installMixtrackGo() {
let preset = MixtrackGoProtocol.mappings(source:sourceID)
let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset
@@ -527,6 +562,11 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
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
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)
}
status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report"
}
private func selectFeedbackOutput(_ name: String) {
@@ -538,9 +578,9 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
if candidate == name.lowercased() || (candidate.contains("mixtrack") && candidate.contains("go")) { outputDestination = endpoint;break }
}
}
func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) {
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))
sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop))
}
private func sendBytes(_ bytes: [UInt8]) {
guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return }
@@ -1163,17 +1203,7 @@ struct DJPreferencesPane: View {
}
}
}
private var devices: some View {
PreferencesSection("DJ Audio Devices") {
VStack(alignment:.leading,spacing:18) {
Picker("Main output",selection:Binding(get:{system.masterUID},set:{system.routeMaster($0)})) { Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } }
Picker("Headphone preview",selection:Binding(get:{system.cueUID},set:{system.stopPreview();system.cueUID = $0})) { Text("None").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } }
HStack { Text("Preview volume");Slider(value:$system.cueLevel,in:0...1) }
Button("Refresh devices") { system.refreshDevices() }
Text("Independent headphone playback requires a different device from main output. Each device uses its default stereo pair. Multichannel master/cue pairs, booth output, microphone input and external mixer routing are not implemented. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary)
}.padding(12)
}.onAppear { system.refreshDevices() }
}
private var devices: some View { DJAudioDevicesPane(system:system) }
private var sound: some View {
VStack(alignment:.leading,spacing:26) {
PreferencesSection("Mixer") {
@@ -1378,7 +1408,7 @@ private struct DJCueEditor: View {
struct MixtrackGoProtocol {
struct Event { var action: String;var value: Double = 1;var relative = false }
var modes = [0,0]
var lowEQ = true
var lowEQ = false
private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false]
static func mappings(source: Int32) -> [DJMIDIMapping] {
var result = [DJMIDIMapping]()
@@ -1421,12 +1451,17 @@ struct MixtrackGoProtocol {
if [UInt8(0x90),0x91].contains(status),[UInt8(0x46),0x47,0x48,0x49].contains(number) { return [.init(action:"Notice: Acapella/instrumental stems are not implemented.")] }
return nil
}
func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) -> [UInt8] {
func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) -> [UInt8] {
var bytes = [UInt8]()
for i in 0..<2 {
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]
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
bytes += [pad,UInt8(0x14+index),on ? 127 : 1]
}
for mode in 0..<4 {
let on = modes[i] == 0 ? mode == 0 : modes[i] == 1 ? mode == 1 : modes[i] == 2 ? (mode == 1 || mode == 2) : modes[i] == 3 ? mode == 2 : mode == 3
bytes += [pad,UInt8(mode+1),on ? 127 : 1]
+12 -3
View File
@@ -27,14 +27,17 @@ struct DJXMLProfile: Codable, Identifiable {
var bindings: [DJXMLBinding]
var issues: [String]
var total: Int
var aliases: [String]?
var adapter: String?
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 else { throw failure("Expected a djay mapping XML property list with schemeVersion 1.") }
(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>()
@@ -70,14 +73,20 @@ struct DJXMLProfile: Codable, Identifiable {
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)
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)
}
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) }
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? {
+3 -3
View File
@@ -796,9 +796,9 @@ struct LibraryView: View {
workspaceLayout
.background(StudioTheme.frame)
.tint(.accentColor)
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty)))
.focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true}))
.sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model) }
.focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:workspaceTab == "Karaoke" && ["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty)))
.focusedSceneValue(\.libraryCommands,workspaceTab == "Karaoke" ? LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true}) : nil)
.sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model,scope:workspaceTab == "DJ Studio" ? "DJ" : "Karaoke") }
.navigationTitle(workspaceTab == "DJ Studio" ? "DJ Studio" : source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source)
.navigationSubtitle(model.currentVenueName)
.toolbar {
+12 -1
View File
@@ -92,13 +92,24 @@ import CoreAudio
private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") }
private var restoringMix = true
private var persistsMix = true
func routeOutput(_ device: AudioDeviceID) throws {
func routeOutput(_ device: AudioDeviceID,firstChannel: Int = 0) throws {
guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") }
let running = engine.isRunning;engine.stop();var value = device
let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value)))
guard result == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio routing failed (\(result))") }
var map = try DJAudioRouting.channelMap(channels:DJAudioDevice.channelCount(device),first:firstChannel)
let mapResult = map.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) }
guard mapResult == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio channel routing failed (\(mapResult))") }
if running { try engine.start() }
}
func outputChannelMap() -> [Int32] {
guard let unit = engine.outputNode.audioUnit else { return [] }
var size: UInt32 = 0
guard AudioUnitGetPropertyInfo(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,&size,nil) == noErr,size > 0 else { return [] }
var values = [Int32](repeating:-1,count:Int(size)/4)
let result = values.withUnsafeMutableBytes { AudioUnitGetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,&size) }
return result == noErr ? values : []
}
struct GlobalMix: Codable, Equatable {
var volume: Double; var low: Double; var mid: Double; var high: Double; var filter: Double; var enabled: Bool
}
+34 -1
View File
@@ -2127,7 +2127,7 @@ extension KJamsCloneTests {
let suite = "KJMix.XMLDetect.\(UUID())",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let profile = try xmlMapping([["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":1]],endpoint:endpoint)
let midi = DJMIDI(defaults:defaults);midi.addXMLProfiles([profile])
let midi = DJMIDI(defaults:defaults,catalog:[profile]);midi.addXMLProfiles([profile])
var uid: Int32 = 0;MIDIObjectGetIntegerProperty(source,kMIDIPropertyUniqueID,&uid)
XCTAssertEqual(midi.sourceID,uid);XCTAssertEqual(midi.activeXML?.id,profile.id)
MIDIEndpointDispose(source);source = 0;midi.refreshSources();XCTAssertEqual(midi.sourceID,0)
@@ -2139,4 +2139,37 @@ extension KJamsCloneTests {
midi.select(0);midi.refreshSources();XCTAssertEqual(midi.sourceID,0);XCTAssertFalse(midi.autoDetect)
XCTAssertFalse(DJMIDI(defaults:defaults,connect:false).autoDetect)
}
@MainActor func testWorkspaceTabsSwitchWhileBothPlayersContinue() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
defer { model.player.stop();model.dj.a.player.stop() }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let library = try XCTUnwrap(model.library)
let id = try library.add(title:"Tab Playback",audio:audio.path,cdg:nil)
let song = try XCTUnwrap(try library.songs().first{$0.id == id})
try model.player.load(song);model.dj.load(song,into:"A");model.dj.a.player.toggle()
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:1000).preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:1320,height:1000)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded()
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSSegmentedControl}.first{$0.segmentCount == 2})
func selectTab(_ index: Int) async throws {
let point = tabs.convert(NSPoint(x:tabs.bounds.width*(index == 0 ? 0.25 : 0.75),y:tabs.bounds.midY),to:nil)
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
}
try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded()
XCTAssertEqual(tabs.selectedSegment,index)
XCTAssertTrue(model.player.playing);XCTAssertTrue(model.dj.a.player.playing)
XCTAssertEqual(model.player.currentSongID,id);XCTAssertEqual(model.dj.a.player.currentSongID,id)
}
try await selectTab(1)
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/dj-tab.png")) }
try await selectTab(0)
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/karaoke-tab.png")) }
}
}