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 controllerPreset(_ device: DJAudioDevice,mode: String) -> Self { var routing = Self();routing.mode = mode routing.main = .init(uid:device.uid,firstChannel:0) routing.deckA = routing.main routing.deckB = .init(uid:device.uid,firstChannel:device.outputChannels >= 4 ? 2 : 0) if mode == "Internal",device.outputChannels >= 4 { routing.cue = .init(uid:device.uid,firstChannel:2) } return routing } func changingMode(_ mode: String,preferred: DJAudioDevice?) -> Self { var next = self;next.mode = mode if mode == "External" { if next.deckA == DJAudioRoute(),next.deckB == DJAudioRoute(),let preferred { next.deckA = .init(uid:preferred.uid,firstChannel:0);next.deckB = .init(uid:preferred.uid,firstChannel:2) } if !next.cue.uid.isEmpty,[next.deckA,next.deckB].contains(next.cue) { next.cue = .init() } } return next } 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() private var recommended: DJAudioDevice? { system.devices.first { $0.name.localizedCaseInsensitiveContains("MixTrack") && $0.outputChannels >= 4 } ?? system.devices.first{$0.outputChannels >= 4} } private var validation: String? { do { try draft.validate(devices:system.devices,defaultID:DJAudioDevice.defaultOutput());return nil } catch { return error.localizedDescription } } var body: some View { VStack(spacing:16) { ScrollView { VStack(alignment:.leading,spacing:22) { PreferencesSection("Audio Routing") { VStack(spacing:22) { HStack { Text("Mixer Mode:").frame(width:130,alignment:.trailing);Picker("Mixer Mode",selection:Binding(get:{draft.mode},set:{draft = draft.changingMode($0,preferred:recommended)})) { 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) } if let recommended { Button("Use \(recommended.name)") { draft = .controllerPreset(recommended,mode:draft.mode) } .help("Use the controller’s stereo pairs immediately") } 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) } if let validation { Text(validation).font(.callout).foregroundStyle(.orange) } PreferencesSection("Detected Audio Devices") { ForEach(system.detectedDevices) { device in HStack(alignment:.top) { Text(device.name).frame(maxWidth:.infinity,alignment:.leading) VStack(alignment:.trailing,spacing:3) { Text("\(device.outputChannels) out · \(device.inputChannels) in") if device.outputChannels >= 2 { Text(device.stereoPairs.map { "ch \($0+1)–\($0+2)" }.joined(separator:", ")).foregroundStyle(.secondary) } else { Text(device.outputChannels == 0 ? "Input only" : "Mono output").foregroundStyle(.secondary) } } }.font(.caption).padding(.vertical,3) } } Text("Booth, microphone and recording input are not available. Karaoke has its own output settings.").font(.caption).foregroundStyle(.secondary) } } Divider() HStack { Button("Refresh Devices") { system.refreshDevices() } Spacer() if validation != nil { Button("Revert invalid changes") { draft = system.routing } } Text("Changes apply immediately").font(.caption).foregroundStyle(.secondary) } }.onAppear { system.refreshDevices();draft = system.routing } .onStudioChange(of:draft) { next in if next != system.routing,validation == nil { system.applyRouting(next) } } .task { while !Task.isCancelled { do { try await Task.sleep(nanoseconds:2_000_000_000) } catch { return } system.refreshDevices() } } } private func row(_ label: String,route: Binding,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 = device?.stereoPairs ?? [0] 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) DJAudioChannelPicker(value:route.firstChannel,pairs:pairs,label:label+" channels") .frame(width:110,height:26).disabled(optional && route.wrappedValue.uid.isEmpty) } } } // Explicit native menus keep every reported channel pair visible and keyboard accessible. struct DJAudioChannelPicker: NSViewRepresentable { @Binding var value: Int let pairs: [Int] let label: String func makeCoordinator() -> Coordinator { Coordinator(value:$value) } func makeNSView(context: Context) -> NSPopUpButton { let button = NSPopUpButton(frame:.zero,pullsDown:false) button.target = context.coordinator;button.action = #selector(Coordinator.changed(_:)) button.setAccessibilityLabel(label);button.identifier = NSUserInterfaceItemIdentifier(label);return button } func updateNSView(_ button: NSPopUpButton,context: Context) { context.coordinator.value = $value if button.itemArray.map(\.tag) != pairs { button.removeAllItems() for pair in pairs { button.addItem(withTitle:"ch \(pair+1)–\(pair+2)");button.lastItem?.tag = pair } } button.selectItem(withTag:value);button.isEnabled = context.environment.isEnabled button.setAccessibilityLabel(label) } func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSPopUpButton,context: Context) -> CGSize? { .init(width:proposal.width ?? 110,height:26) } final class Coordinator: NSObject { var value: Binding init(value: Binding) { self.value = value } @objc func changed(_ sender: NSPopUpButton) { if let selected = sender.selectedItem { value.wrappedValue = selected.tag } } } }