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
+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]