broken changes

This commit is contained in:
Mark Gregory
2026-11-05 17:06:40 +00:00
parent 9da6d2fdcd
commit 8cffb40f17
3 changed files with 169 additions and 35 deletions
+86 -8
View File
@@ -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<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))
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<Int>
init(value: Binding<Int>) { self.value = value }
@objc func changed(_ sender: NSPopUpButton) { if let selected = sender.selectedItem { value.wrappedValue = selected.tag } }
}
}
+51 -27
View File
@@ -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<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] {
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<CFString>?;var bytes = UInt32(MemoryLayout<CFString?>.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<Void,Never>?
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) }
}
}
+32
View File
@@ -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 {