broken changes

This commit is contained in:
Mark Gregory
2026-11-05 17:21:18 +00:00
parent 8cffb40f17
commit 83a97ff3fd
5 changed files with 72 additions and 39 deletions
+18 -12
View File
@@ -20,6 +20,16 @@ struct DJAudioRouting: Codable, Equatable {
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
@@ -67,7 +77,7 @@ struct DJAudioDevicesPane: View {
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() }
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)
@@ -76,7 +86,7 @@ struct DJAudioDevicesPane: View {
}
if let recommended {
Button("Use \(recommended.name)") { draft = .controllerPreset(recommended,mode:draft.mode) }
.help("Set up the controller’s stereo pairs, then Apply")
.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) }
@@ -89,7 +99,7 @@ struct DJAudioDevicesPane: View {
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) }
} else { Text(device.outputChannels == 0 ? "Input only" : "Mono output").foregroundStyle(.secondary) }
}
}.font(.caption).padding(.vertical,3)
}
@@ -101,18 +111,14 @@ struct DJAudioDevicesPane: View {
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 || validation != nil)
if validation != nil { Button("Revert invalid changes") { draft = system.routing } }
Text("Changes apply immediately").font(.caption).foregroundStyle(.secondary)
}
}.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() }
}
.onChange(of:draft) { next in
if next != system.routing,validation == nil { system.applyRouting(next) }
}
.task {
while !Task.isCancelled {
@@ -147,7 +153,7 @@ struct DJAudioChannelPicker: NSViewRepresentable {
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
button.setAccessibilityLabel(label);button.identifier = NSUserInterfaceItemIdentifier(label);return button
}
func updateNSView(_ button: NSPopUpButton,context: Context) {
context.coordinator.value = $value
+1
View File
@@ -52,6 +52,7 @@ import UniformTypeIdentifiers
func manualTakeover() { if enabled { stop("Automix off · manual control") } }
func start(runTimer: Bool = true) {
guard let system,!enabled else { return }
guard !system.importing else { message = "Wait for the current DJ import to finish";return }
guard system.routing.mode == "Internal" else { message = "Automix requires Internal mixer mode";return }
let playing = [system.a,system.b].filter{$0.player.playing}
guard playing.count <= 1 else { message = "Pause one deck before starting Automix";return }
+1 -1
View File
@@ -663,7 +663,7 @@ struct StudioSettingsDialog: View {
else if category == "Devices" { KaraokeDevicesPreferences(model:model) }
else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) }
Divider()
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Use Apply to save device changes" : scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
HStack { Text(scope == "DJ" && category == "Devices" ? "DJ routing · Changes apply immediately" : scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16)
}.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor))
.onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } }
.onChange(of:category) { _ in model.dj.midi.learning = nil }
+5 -18
View File
@@ -773,24 +773,11 @@ struct LibraryView: View {
}
}
private var workspaceLayout: some View {
VStack(spacing:0) {
HStack {
Spacer()
Picker("Workspace",selection:$workspaceTab) {
Label("Karaoke",systemImage:"music.mic").tag("Karaoke")
Label("DJ Studio",systemImage:"opticaldisc").tag("DJ Studio")
}.pickerStyle(.segmented).frame(width:340).accessibilityIdentifier("workspace.tabs")
Spacer()
}.frame(height:43).background(.bar)
Divider()
// Keep both workspaces mounted: playback, selection and MIDI stay alive between tabs.
ZStack {
libraryWorkspace.opacity(workspaceTab == "Karaoke" ? 1 : 0)
.allowsHitTesting(workspaceTab == "Karaoke").accessibilityHidden(workspaceTab != "Karaoke")
DJConsole(model:model,system:model.dj,expanded:true).opacity(workspaceTab == "DJ Studio" ? 1 : 0)
.allowsHitTesting(workspaceTab == "DJ Studio").accessibilityHidden(workspaceTab != "DJ Studio")
}.frame(maxWidth:.infinity,maxHeight:.infinity)
}
TabView(selection:$workspaceTab) {
libraryWorkspace.tabItem { Label("Karaoke",systemImage:"music.mic") }.tag("Karaoke")
DJConsole(model:model,system:model.dj,expanded:true)
.tabItem { Label("DJ Studio",systemImage:"opticaldisc") }.tag("DJ Studio")
}.accessibilityIdentifier("workspace.tabs")
}
private var workspace: some View {
workspaceLayout
+47 -8
View File
@@ -1352,7 +1352,10 @@ final class KJamsCloneTests: XCTestCase {
try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded();host.displayIfNeeded()
if let image = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:image) }
func click(_ x: Double,_ top: Double) async throws {
let point = NSPoint(x:x,y:900-top-44)
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first)
let rect = tabs.contentRect
let point = tabs.convert(NSPoint(x:rect.minX+x,y:tabs.isFlipped ? rect.minY+top : rect.maxY-top),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)
@@ -1497,8 +1500,12 @@ final class KJamsCloneTests: XCTestCase {
host.layoutSubtreeIfNeeded();if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-rotation-test-"+name+".png")) }
}
func click(_ x: Double,_ top: Double) async throws {
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first)
let rect = tabs.contentRect
let point = tabs.convert(NSPoint(x:rect.minX+x,y:tabs.isFlipped ? rect.minY+top : rect.maxY-top),to:nil)
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:760-top-44),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
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:120_000_000)
}
@@ -2104,6 +2111,9 @@ extension KJamsCloneTests {
XCTAssertEqual(device.stereoPairs,[0,2])
let preset = DJAudioRouting.controllerPreset(device,mode:"Internal")
XCTAssertEqual(preset.main.firstChannel,0);XCTAssertEqual(preset.cue.firstChannel,2)
let switched = preset.changingMode("External",preferred:device)
XCTAssertTrue(switched.cue.uid.isEmpty,"Switching to external clears cue if both pairs are used by public decks")
XCTAssertNoThrow(try switched.validate(devices:[device],defaultID:1))
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))
@@ -2114,6 +2124,22 @@ extension KJamsCloneTests {
XCTAssertNoThrow(try routing.validate(devices:[device],defaultID:1))
routing.deckB.firstChannel = 0;XCTAssertThrowsError(try routing.validate(devices:[device],defaultID:1))
}
@MainActor func testNativeChannelPickerListsBothPairsAndChangesBindingImmediately() async throws {
var selected = 0
let host = NSHostingView(rootView:DJAudioChannelPicker(value:Binding(get:{selected},set:{selected = $0}),pairs:[0,2],label:"Test channels").frame(width:140,height:30))
host.frame = NSRect(x:0,y:0,width:140,height:30)
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)
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded()
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let control = try XCTUnwrap(descendants(host).compactMap{$0 as? NSPopUpButton}.first)
XCTAssertEqual(control.itemTitles,["ch 1–2","ch 3–4"])
control.selectItem(withTag:2);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target)
XCTAssertEqual(selected,2)
control.selectItem(withTag:0);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target)
XCTAssertEqual(selected,0)
}
@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:", "))
@@ -2140,6 +2166,19 @@ extension KJamsCloneTests {
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")
let main = try XCTUnwrap(menus.first{$0.identifier?.rawValue == "Main Output channels"})
let cue = try XCTUnwrap(menus.first{$0.identifier?.rawValue == "Pre-Cueing channels"})
func choose(_ button: NSPopUpButton,_ pair: Int) async throws {
button.selectItem(withTag:pair);_ = button.sendAction(try XCTUnwrap(button.action),to:button.target)
try await Task.sleep(nanoseconds:100_000_000)
}
try await choose(main,2)
XCTAssertEqual(model.dj.routing.main.firstChannel,0,"Conflicting draft keeps the last valid live route")
try await choose(cue,0)
XCTAssertEqual(model.dj.routing.main.firstChannel,2);XCTAssertEqual(model.dj.routing.cue.firstChannel,0)
XCTAssertEqual(model.dj.a.player.outputChannelMap(),[-1,-1,0,1],"Native selection applies immediately to the actual output unit")
try await choose(main,0);try await choose(cue,2)
XCTAssertEqual(model.dj.routing.main.firstChannel,0);XCTAssertEqual(model.dj.routing.cue.firstChannel,2)
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 {
@@ -2270,14 +2309,14 @@ extension KJamsCloneTests {
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})
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first{$0.numberOfTabViewItems == 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)
}
tabs.selectTabViewItem(at:index)
try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded()
XCTAssertEqual(tabs.selectedSegment,index)
XCTAssertEqual(tabs.selectedTabViewItem,tabs.tabViewItem(at:index))
if let inactive = tabs.tabViewItem(at:1-index).view {
XCTAssertTrue(inactive.superview == nil || inactive.isHiddenOrHasHiddenAncestor,"Inactive pages cannot intercept deck drops")
}
XCTAssertTrue(model.player.playing);XCTAssertTrue(model.dj.a.player.playing)
XCTAssertEqual(model.player.currentSongID,id);XCTAssertEqual(model.dj.a.player.currentSongID,id)
}