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