diff --git a/KJamsClone/DJAudioRouting.swift b/KJamsClone/DJAudioRouting.swift index e4b1926..622773f 100644 --- a/KJamsClone/DJAudioRouting.swift +++ b/KJamsClone/DJAudioRouting.swift @@ -12,6 +12,14 @@ struct DJAudioRouting: Codable, Equatable { 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 + } 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 @@ -48,8 +56,15 @@ struct DJAudioRouting: Codable, Equatable { 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(alignment:.leading,spacing:26) { + 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:$draft.mode) { Text("Internal").tag("Internal");Text("External").tag("External") }.pickerStyle(.radioGroup).horizontalRadioGroupLayout().labelsHidden();Spacer() } @@ -59,20 +74,56 @@ struct DJAudioDevicesPane: View { 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("Set up the controller’s stereo pairs, then Apply") + } 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("Input only").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() Button("Revert") { draft = system.routing }.disabled(draft == system.routing) - Button("Apply") { system.applyRouting(draft) }.buttonStyle(.borderedProminent).disabled(draft == system.routing) + Button("Apply") { system.applyRouting(draft) }.buttonStyle(.borderedProminent).disabled(draft == system.routing || validation != nil) } - 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 } + }.onAppear { + system.refreshDevices();draft = system.routing + if draft == DJAudioRouting(),let recommended { draft = .controllerPreset(recommended,mode:"Internal") } + } + .onChange(of:draft.mode) { mode in + if mode == "External",draft.deckA == DJAudioRoute(),draft.deckB == DJAudioRoute(),let recommended { + draft.deckA = .init(uid:recommended.uid,firstChannel:0);draft.deckB = .init(uid:recommended.uid,firstChannel:2) + if draft.cue.uid == recommended.uid { draft.cue = .init() } + } + } + .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 = Array(stride(from:0,to:max(0,(device?.outputChannels ?? 2)-1),by:2)) + 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)})) { @@ -81,9 +132,36 @@ struct DJAudioDevicesPane: View { 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) + 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);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 } } + } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 444bc9c..4a6ff68 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -113,15 +113,18 @@ struct DJAudioDevice: Identifiable, Equatable { 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 inputChannels = 0 + var stereoPairs: [Int] { Array(stride(from:0,to:max(0,outputChannels-1),by:2)) } + static func channelCount(_ id: AudioDeviceID,scope: AudioObjectPropertyScope = kAudioDevicePropertyScopeOutput) -> Int { + var address = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreamConfiguration,mScope:scope,mElement:kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(id,&address,0,nil,&size) == noErr,size >= MemoryLayout.size else { return 0 } let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout.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] { + static func outputs() -> [Self] { allDevices().filter { $0.outputChannels > 0 } } + static func allDevices() -> [Self] { var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var size: UInt32 = 0 guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size) == noErr else { return [] } @@ -129,15 +132,15 @@ struct DJAudioDevice: Identifiable, Equatable { let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) } guard result == noErr else { return [] } return ids.compactMap { id in - let channels = channelCount(id) - guard channels > 0 else { return nil } + let channels = channelCount(id),inputs = channelCount(id,scope:kAudioDevicePropertyScopeInput) + guard channels > 0 || inputs > 0 else { return nil } func string(_ selector: AudioObjectPropertySelector) -> String? { var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var text: Unmanaged?;var bytes = UInt32(MemoryLayout.size) 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",outputChannels:channels) + return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channels,inputChannels:inputs) } } static func defaultOutput() -> AudioDeviceID? { @@ -165,6 +168,7 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } } @Published private(set) var previewDeck: String? @Published var devices = [DJAudioDevice]() + @Published private(set) var detectedDevices = [DJAudioDevice]() @Published var masterUID = "" @Published var cueUID = "" @Published private(set) var routing = DJAudioRouting() @@ -180,6 +184,7 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var importProgress: ImportProgress? @Published private(set) var importing = false private var importTask: Task? + private var queuedFileLoads = [(urls:[URL],deck:String,override:Bool,song:Int64?)]() init(storageURL: URL? = nil) { root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ") preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix() @@ -231,10 +236,17 @@ struct DJAudioDevice: Identifiable, Equatable { self.perform { try self.refresh() } if let failure = result.failure { self.error = failure } else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) } + self.continueFileLoads() } } } - func cancelImport() { importTask?.cancel() } + func cancelImport() { queuedFileLoads = [];importTask?.cancel() } + func continueFileLoads() { + guard !importing,pendingReplacement == nil,!queuedFileLoads.isEmpty else { return } + let next = queuedFileLoads.removeFirst() + requestFileLoad(next.urls,into:next.deck,override:next.override && channel(next.deck).song?.id == next.song) + } + func cancelReplacement() { pendingReplacement = nil;continueFileLoads() } deinit { importTask?.cancel() } func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } @@ -257,7 +269,12 @@ struct DJAudioDevice: Identifiable, Equatable { status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops" } } - func refreshDevices() { devices = DJAudioDevice.outputs();midi.refreshSources() } + func refreshDevices() { + let all = DJAudioDevice.allDevices(),outputs = all.filter{$0.outputChannels > 0} + if detectedDevices != all { detectedDevices = all } + if devices != outputs { devices = outputs } + midi.refreshSources() + } private func device(_ uid: String) throws -> AudioDeviceID { if uid.isEmpty,let id = DJAudioDevice.defaultOutput() { return id } guard let output = devices.first(where:{$0.uid == uid}) else { throw LibraryError.message("Selected audio output is disconnected. Refresh and choose an available output.") } @@ -713,7 +730,7 @@ struct DJConsole: View { }.background(DJStyle.background).foregroundStyle(.white) .preferredColorScheme(.dark) .alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in - Button("Cancel",role:.cancel) { system.pendingReplacement = nil } + Button("Cancel",role:.cancel) { system.cancelReplacement() } Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) } } message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } @@ -1084,8 +1101,8 @@ struct DJContinuousConsole: View { DJConsoleWing(deck:system.a,player:system.a.player,system:system) Divider() VStack(spacing:1) { - DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) - DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) + DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"A") } + DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"B") } }.frame(maxWidth:.infinity).background(.black) Divider() DJConsoleWing(deck:system.b,player:system.b.player,system:system) @@ -1130,7 +1147,7 @@ private struct DJTrackHeader: View { } if deck.letter == "B" { artwork } }.padding(10).frame(maxWidth:.infinity) - .onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } + .contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } } private var artwork: some View { ZStack { @@ -1162,7 +1179,7 @@ private struct DJConsoleWing: View { if deck.letter == "A" { face.tonePanel } if deck.letter == "B" { face.tempoStrip } }.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1) - .onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } + .contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } } } private struct DJConsoleTransport: View { @@ -1238,18 +1255,23 @@ struct DJPreferencesPane: View { @ObservedObject var system: DJSystem let category: String var body: some View { - ScrollView { - VStack(alignment:.leading,spacing:26) { - switch category { - case "General": general - case "Devices": devices - case "Sound": sound - case "Library": library - case "Appearance": appearance - case "MIDI": midi - default: advanced + Group { + if category == "Devices" { + DJAudioDevicesPane(system:system).padding(26) + } else { + ScrollView { + VStack(alignment:.leading,spacing:26) { + switch category { + case "General": general + case "Sound": sound + case "Library": library + case "Appearance": appearance + case "MIDI": midi + default: advanced + } + }.padding(26).frame(maxWidth:.infinity,alignment:.leading) } - }.padding(26).frame(maxWidth:.infinity,alignment:.leading) + } }.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } } private var general: some View { @@ -1270,7 +1292,6 @@ struct DJPreferencesPane: View { } } } - private var devices: some View { DJAudioDevicesPane(system:system) } private var sound: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Mixer") { @@ -1382,12 +1403,15 @@ struct DJReplacement: Identifiable { func requestFileLoad(_ urls: [URL],into letter: String,override: Bool = false) { guard !urls.isEmpty else { return } if channel(letter).player.playing,!override { pendingReplacement = .init(deck:letter,song:nil,urls:urls);return } - guard !importing else { status = "Wait for the current DJ import to finish.";return } + if importing { + queuedFileLoads.append((urls,letter,override,channel(letter).song?.id)) + status = "Queued file drop for deck \(letter) · waiting for the current import";return + } importFiles(urls,loadInto:letter,overridePlaying:override) } func confirmReplacement(_ captured: DJReplacement? = nil) { guard let request = captured ?? pendingReplacement else { return };pendingReplacement = nil - if let song = request.song { load(song,into:request.deck,overridePlaying:true) } + if let song = request.song { load(song,into:request.deck,overridePlaying:true);continueFileLoads() } else { requestFileLoad(request.urls,into:request.deck,override:true) } } } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 5a8ffdc..842e7f2 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1998,6 +1998,27 @@ extension KJamsCloneTests { XCTAssertNil(system.error);XCTAssertNil(system.pendingReplacement);XCTAssertEqual(system.songs.count,1) XCTAssertNotEqual(system.a.song?.id,999);XCTAssertFalse(system.a.player.playing) } + @MainActor func testSequentialAndConcurrentFinderDropsLoadBothDecksIncludingDuplicates() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let system = DJSystem(storageURL:root);defer { system.a.unload();system.b.unload() } + let first = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let second = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/TestTrack.wav")) + system.requestFileLoad([first],into:"A") + XCTAssertTrue(system.importing) + system.requestFileLoad([second],into:"B") + for _ in 0..<500 { if system.b.song != nil,!system.importing { break };try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertNotNil(system.a.song);XCTAssertNotNil(system.b.song,system.error ?? system.status) + let original = system.a.song?.id + XCTAssertNotEqual(system.b.song?.id,original) + system.requestFileLoad([first],into:"B") + for _ in 0..<300 { if !system.importing { break };try await Task.sleep(nanoseconds:10_000_000) } + XCTAssertEqual(system.b.song?.id,original,"Dropping a previously imported file onto the other deck still loads it") + XCTAssertEqual(system.a.song?.id,original);XCTAssertNil(system.error) + XCTAssertTrue(system.acceptDrop([system.dragReference(try XCTUnwrap(original)).provider()],into:"A")) + try await Task.sleep(nanoseconds:50_000_000) + XCTAssertEqual(system.a.song?.id,original) + } @MainActor func testArtworkSelectsTrackJPGAndLoadsFromOriginalFolder() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } @@ -2080,6 +2101,12 @@ extension KJamsCloneTests { XCTAssertEqual(try DJAudioRouting.channelMap(channels:4,first:0),[0,1,-1,-1]) XCTAssertThrowsError(try DJAudioRouting.channelMap(channels:2,first:2)) let device = DJAudioDevice(id:1,uid:"controller",name:"Controller",outputChannels:4) + XCTAssertEqual(device.stereoPairs,[0,2]) + let preset = DJAudioRouting.controllerPreset(device,mode:"Internal") + XCTAssertEqual(preset.main.firstChannel,0);XCTAssertEqual(preset.cue.firstChannel,2) + let external = DJAudioRouting.controllerPreset(device,mode:"External") + XCTAssertEqual(external.deckA.firstChannel,0);XCTAssertEqual(external.deckB.firstChannel,2) + XCTAssertNoThrow(try external.validate(devices:[device],defaultID:1)) var routing = DJAudioRouting();routing.main.uid = device.uid;routing.cue = .init(uid:device.uid,firstChannel:2) XCTAssertNoThrow(try routing.validate(devices:[device],defaultID:1)) routing.cue.firstChannel = 0;XCTAssertThrowsError(try routing.validate(devices:[device],defaultID:1)) @@ -2089,6 +2116,7 @@ extension KJamsCloneTests { } @MainActor func testAudioOutputsAndActualFourChannelRoutingWhenInterfaceAttached() async throws { let outputs = DJAudioDevice.outputs() + print("DJ audio discovery:",outputs.map { "\($0.name) [\($0.outputChannels) out / \($0.inputChannels) in]" }.joined(separator:", ")) XCTAssertFalse(outputs.isEmpty,"CoreAudio should expose available Mac and USB outputs") guard let controller = outputs.first(where:{$0.name.localizedCaseInsensitiveContains("MixTrack Go") && $0.outputChannels >= 4}) else { throw XCTSkip("Four-channel Mixtrack Go is not attached") } let player = Player(persistsMix:false) @@ -2108,6 +2136,10 @@ extension KJamsCloneTests { window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil) defer { window.orderOut(nil);window.contentView = nil } try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded() + func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} } + let menus = descendants(host).compactMap{$0 as? NSPopUpButton} + for menu in menus { menu.menu?.update() } + XCTAssertTrue(menus.contains{$0.itemTitles.contains("ch 1–2") && $0.itemTitles.contains("ch 3–4")},"Native channel menus expose both pairs from the attached Numark") 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/audio-routing.png")) } } func testXMLStandardLEDOutputHonoursOverridesAndInversion() throws {