broken changes
This commit is contained in:
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user