From 13a6a9e1c894704e16bad62822facd15d247022a Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 16:43:08 +0000 Subject: [PATCH] broken changes --- KJamsClone/DJWorkspace.swift | 2 +- KJamsClone/DJWorkstation.swift | 57 ++++++++++++++++---- KJamsClone/DJXMLMapping.swift | 32 +++++++++-- KJamsCloneTests/KJamsCloneTests.swift | 78 ++++++++++++++++++++++++++- README.md | 2 +- docs/DJ_WORKSTATION.md | 28 ++++++---- 6 files changed, 173 insertions(+), 26 deletions(-) diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index c576801..220b90e 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -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+" 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 · 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) }.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 } diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 87211fc..0b47e69 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -430,6 +430,8 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { var activeXML: DJXMLProfile? { profiles[String(sourceID)]?.hasPrefix("XML:") == true ? xmlAssignments[String(sourceID)] : nil } private var mixtrack = MixtrackGoProtocol() private var outputPort = MIDIPortRef(0),outputDestination = MIDIEndpointRef(0) + private var initializationTask: Task? + private var lastFeedback = [UInt8]() @Published var learning: String? @Published var learnMode = "Absolute" @Published var status = "Choose a MIDI source; learn each control. No assumed factory mapping." @@ -483,6 +485,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { } } private func disconnect() { + initializationTask?.cancel();initializationTask = nil;lastFeedback = [] if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = [] } func select(_ id: Int32) { @@ -494,8 +497,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if autoDetect,profiles[String(id)] == nil { if let profile = DJXMLProfile.match(source.name,in:xmlCatalog) { applyXML(profile) } else if DJXMLProfile.normalized(source.name) == "numark mixtrack go" { installMixtrackGo() } - } - if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) } + } else if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) } } else { status = "Could not connect MIDI source (\(result))" } } @@ -541,11 +543,11 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { let preset = MixtrackGoProtocol.mappings(source:sourceID) let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");mixtrack = .init();save() - sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]) + initializeMixtrack() status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pad modes. Hardware testing required." onAction?("Controller feedback",0,false) } - func useCustomProfile() { profiles[String(sourceID)] = "Custom";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" } + func useCustomProfile() { initializationTask?.cancel();lastFeedback = [];profiles[String(sourceID)] = "Custom";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" } func mergeXML(_ imported: [DJXMLProfile]) { let ids = Set(imported.map(\.id));xmlCatalog.removeAll { ids.contains($0.id) };xmlCatalog += imported xmlCatalog.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } @@ -565,22 +567,56 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if profile.adapter == "Mixtrack Go" { mixtrack = .init();let preset = MixtrackGoProtocol.mappings(source:sourceID) let ids = Set(preset.map(\.id));mappings.removeAll { ids.contains($0.id) };mappings += preset;save() - sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]);onAction?("Controller feedback",0,false) + initializeMixtrack() } + onAction?("Controller feedback",0,false) status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report" } + private func initializeMixtrack() { + initializationTask?.cancel();lastFeedback = [] + sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]) + sendBytes([0xF0,0x00,0x20,0x7F,0x03,0x01,0xF7]) + let source = sourceID,destination = outputDestination + // The controller can clear LEDs as its startup demo stops. Refresh after that transition. + initializationTask = Task { [weak self] in + do { try await Task.sleep(nanoseconds:2_000_000_000) } catch { return } + guard let self,self.sourceID == source,self.outputDestination == destination,self.mixtrackEnabled else { return } + self.onAction?("Controller feedback",0,false);self.sendBytes(self.lastFeedback) + } + } private func selectFeedbackOutput(_ name: String) { outputDestination = 0 for i in 0..? MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&result) let candidate = (result?.takeRetainedValue() as String? ?? "").lowercased() - if candidate == name.lowercased() || (candidate.contains("mixtrack") && candidate.contains("go")) { outputDestination = endpoint;break } + if candidate == name.lowercased() { outputDestination = endpoint;break } } } func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) { - guard mixtrackEnabled else { return } - sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop)) + if !mixtrackEnabled { + guard let outputs = activeXML?.outputs else { return } + for output in outputs { + let parts = output.action.split(separator:" ",maxSplits:1).map(String.init) + guard parts.count == 2 else { continue } + let isA = parts[0] == "A",loaded = isA ? aLoaded : bLoaded,cues = isA ? aCues : bCues + let active: Bool + switch parts[1] { + case "Play": active = isA ? aPlaying : bPlaying + case "Cue button": active = loaded + case "Sync": active = sync + case "Headphones": active = cue == parts[0] + case "Loop": active = isA ? aLoop : bLoop + default: + let index = (Int(parts[1].split(separator:" ").last ?? "0") ?? 0)-1 + active = cues.indices.contains(index) && cues[index] + } + sendBytes(output.packet(active:active)) + } + return + } + lastFeedback = mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop) + sendBytes(lastFeedback) } private func sendBytes(_ bytes: [UInt8]) { guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return } @@ -593,7 +629,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { } func remove(_ id: String) { mappings.removeAll{$0.id == id};save() } private func save() { if let data = try? JSONEncoder().encode(mappings) { defaults.set(data,forKey:"DJ.MIDIMappings.v1") } } - deinit { if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } } + deinit { initializationTask?.cancel();if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } } } private enum DJStyle { @@ -1027,7 +1063,7 @@ struct DJContinuousConsole: View { HStack(spacing:12) { DJConsoleTransport(deck:system.a,player:system.a.player,system:system) VStack(spacing:0) { - DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36) + DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36).disabled(system.routing.mode == "External") HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("Centre") { system.crossfader = 0.5 }.buttonStyle(.plain);Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.system(size:9)) }.frame(width:195) DJConsoleTransport(deck:system.b,player:system.b.player,system:system) @@ -1457,6 +1493,7 @@ struct MixtrackGoProtocol { let status = UInt8(0x90+i),pad = UInt8(0x94+i),loaded = i == 0 ? aLoaded : bLoaded,playing = i == 0 ? aPlaying : bPlaying bytes += [status,0x00,playing ? 127 : (loaded ? 1 : 0),status,0x01,loaded ? 1 : 0,status,0x02,sync ? 127 : 0] bytes += [UInt8(0x80+i),0x1B,cue == (i == 0 ? "A" : "B") ? 127 : 1] + bytes += [status,0x04,playing ? 127 : 1,status,0x05,loaded ? 127 : 1,status,0x03,sync ? 127 : 1,0x9F,UInt8(0x02+i),loaded ? 127 : 1] for index in 0..<4 { let cues = i == 0 ? aCues : bCues,loop = i == 0 ? aLoop : bLoop let on = modes[i] == 0 ? (cues.indices.contains(index) && cues[index]) : modes[i] == 1 && loop diff --git a/KJamsClone/DJXMLMapping.swift b/KJamsClone/DJXMLMapping.swift index 53e8d17..d2f2f1a 100644 --- a/KJamsClone/DJXMLMapping.swift +++ b/KJamsClone/DJXMLMapping.swift @@ -20,6 +20,27 @@ struct DJXMLBinding: Codable, Equatable { } } +struct DJXMLOutput: Codable { + var key: String + var action: String + var status: UInt8 + var number: UInt8 + var on: UInt8 + var off: UInt8 + var flipped: Bool + func packet(active: Bool) -> [UInt8] { [status,number,(active != flipped) ? on : off] } + static func parse(_ output: [String:Any],binding: DJXMLBinding) -> Self? { + let supported = ["A Play","B Play","A Cue button","B Cue button","A Sync","B Sync","A Headphones","B Headphones","A Loop","B Loop"] + ["A","B"].flatMap { d in (1...4).map{d+" Hot cue \($0)"} } + guard supported.contains(binding.action),binding.status & 0xF0 == 0x90 else { return nil } + let channel = output["midiChannel"] as? Int ?? Int(binding.status & 15) + let number = output["midiData"] as? Int ?? Int(binding.number) + let type = output["midiMessageType"] as? Int ?? 1 + guard (0...15).contains(channel),(0...127).contains(number),[0,1,3].contains(type) else { return nil } + func byte(_ key: String,_ fallback: Int) -> UInt8 { UInt8(min(127,max(0,(output[key] as? NSNumber)?.intValue ?? fallback))) } + return .init(key:binding.key,action:binding.action,status:UInt8((type == 0 ? 0x80 : type == 1 ? 0x90 : 0xB0)+channel),number:UInt8(number),on:byte("midiMaxValue",127),off:byte("midiDisabledValue",Int(byte("midiMinValue",0))),flipped:output["flipped"] as? Bool ?? false) + } +} + struct DJXMLProfile: Codable, Identifiable { var id: String var name: String @@ -29,6 +50,7 @@ struct DJXMLProfile: Codable, Identifiable { var total: Int var aliases: [String]? var adapter: String? + var outputs: [DJXMLOutput]? var report: String { "\(name)\n\(bindings.count) supported bindings of \(total) control entries.\n\n" + (adapter == "Mixtrack Go" ? "Uses the dedicated Mixtrack Go adapter for 14-bit tempo, relative jog, pad modes and transport/mode feedback.\n\n" : "") + @@ -40,7 +62,7 @@ struct DJXMLProfile: Codable, Identifiable { (root["schemeVersion"] as? Int == 1 || root["schemeVersion"] as? String == "1") else { throw failure("Expected a djay mapping XML property list with schemeVersion 1.") } let controls = root["controls"] as? [[String:Any]] ?? [] guard controls.count <= 20000 else { throw failure("Mapping contains too many controls.") } - var bindings = [DJXMLBinding](),issues = Set(),blocked = Set() + var bindings = [DJXMLBinding](),outputs = [DJXMLOutput](),issues = Set(),blocked = Set() func address(_ c: [String:Any]) -> (UInt8,UInt8)? { guard let channel = c["midiChannel"] as? Int,(0...15).contains(channel),let number = c["midiData"] as? Int,(0...127).contains(number),let type = c["midiMessageType"] as? Int,[1,3,6].contains(type) else { return nil } return (UInt8((type == 1 ? 0x90 : type == 3 ? 0xB0 : 0xE0)+channel),UInt8(number)) @@ -63,13 +85,17 @@ struct DJXMLProfile: Codable, Identifiable { blocked.insert(key);issues.insert("\(path): relative control is unsupported for this action.");continue } bindings.append(.init(status:status,number:number,action:action,mode:mode,flipped:c["flipped"] as? Bool ?? false,pickup:c["pickupMode"] as? Bool ?? false)) - if c["output"] != nil { issues.insert("\(path): XML LED/display output is not implemented; input is supported where listed.") } + if let output = c["output"] as? [String:Any] { + if let feedback = DJXMLOutput.parse(output,binding:bindings.last!) { outputs.append(feedback) } + else { issues.insert("\(path): this XML output definition is not supported.") } + } } let grouped = Dictionary(grouping:bindings,by:{$0.key}) for (key,group) in grouped where Set(group.map(\.action)).count > 1 || Set(group.map(\.mode)).count > 1 || Set(group.map(\.flipped)).count > 1 { blocked.insert(key);issues.insert("MIDI \(key): conflicting assignments disabled.") } bindings = grouped.keys.sorted().compactMap { blocked.contains($0) ? nil : grouped[$0]?.first } + outputs.removeAll { blocked.contains($0.key) } if !blocked.isEmpty { issues.insert("All bindings sharing an unsupported/conditional address are disabled to prevent accidental actions.") } if root["customClassName"] != nil { issues.insert("Device-specific djay custom processor is unavailable; only independent standard MIDI bindings are enabled.") } if root["outputs"] != nil { issues.insert("Device output/display definitions are not executed.") } @@ -79,7 +105,7 @@ struct DJXMLProfile: Codable, Identifiable { bindings = MixtrackGoProtocol.mappings(source:0).map { .init(status:$0.status,number:$0.number,action:$0.action,mode:$0.mode,flipped:false,pickup:false) } issues.insert("Mixtrack Go uses KJMix Pro’s dedicated protocol adapter, including its pad-mode state. Stems, sampler and FX modes remain unavailable.") } - return .init(id:root["uuid"] as? String ?? filename,name:filename,endpoint:aliases.first ?? filename,bindings:bindings,issues:issues.sorted(),total:controls.filter{$0["headingTitle"] == nil}.count,aliases:aliases,adapter:adapter) + return .init(id:root["uuid"] as? String ?? filename,name:filename,endpoint:aliases.first ?? filename,bindings:bindings,issues:issues.sorted(),total:controls.filter{$0["headingTitle"] == nil}.count,aliases:aliases,adapter:adapter,outputs:outputs) } static func failure(_ message: String) -> NSError { NSError(domain:"DJ XML Mapping",code:1,userInfo:[NSLocalizedDescriptionKey:message]) } static func normalized(_ name: String) -> String { diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 50b993a..8f4b349 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1767,7 +1767,7 @@ extension KJamsCloneTests { XCTAssertEqual(MIDISourceCreate(client,"KJMix virtual test controller" as CFString,&source),noErr) let suite = "KJMix.VirtualMIDI.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite)) defer { defaults.removePersistentDomain(forName:suite) } - let midi = DJMIDI(defaults:defaults);midi.refreshSources() + let midi = DJMIDI(defaults:defaults,catalog:[]);midi.refreshSources() let input = try XCTUnwrap(midi.sources.first{$0.endpoint == source});midi.select(input.id) XCTAssertEqual(midi.sourceID,input.id) XCTAssertEqual(defaults.integer(forKey:"DJ.MIDISource.v1"),Int(input.id)) @@ -2075,6 +2075,82 @@ extension KJamsCloneTests { let data = try PropertyListSerialization.data(fromPropertyList:["schemeVersion":1,"uuid":"xml-test","endpointName":endpoint,"controls":controls],format:.xml,options:0) return try DJXMLProfile.parse(data,filename:endpoint) } + func testStereoRoutingMapsAndRejectsPublicCueOverlap() throws { + XCTAssertEqual(try DJAudioRouting.channelMap(channels:4,first:2),[-1,-1,0,1]) + 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) + 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)) + routing.cue.uid = "";routing.mode = "External";routing.deckA.uid = device.uid;routing.deckB = .init(uid:device.uid,firstChannel:2) + XCTAssertNoThrow(try routing.validate(devices:[device],defaultID:1)) + routing.deckB.firstChannel = 0;XCTAssertThrowsError(try routing.validate(devices:[device],defaultID:1)) + } + @MainActor func testAudioOutputsAndActualFourChannelRoutingWhenInterfaceAttached() async throws { + let outputs = DJAudioDevice.outputs() + 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) + try player.routeOutput(controller.id,firstChannel:2) + XCTAssertEqual(player.outputChannelMap(),[-1,-1,0,1]) + try player.routeOutput(controller.id,firstChannel:0) + XCTAssertEqual(player.outputChannelMap(),[0,1,-1,-1]) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + var routing = DJAudioRouting();routing.main.uid = controller.uid;routing.cue = .init(uid:controller.uid,firstChannel:2) + model.dj.applyRouting(routing);XCTAssertNil(model.dj.error);XCTAssertEqual(model.dj.routing,routing) + XCTAssertEqual(DJSystem(storageURL:model.dj.root).routing,routing) + let host = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:"DJ",category:"Devices").preferredColorScheme(.dark)) + host.frame = NSRect(x:0,y:0,width:850,height:700) + 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:150_000_000);host.layoutSubtreeIfNeeded() + 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 { + let profile = try xmlMapping([["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":4,"output":["midiChannel":2,"midiData":9,"midiMessageType":3,"midiMinValue":1,"midiMaxValue":100,"flipped":true]]]) + let output = try XCTUnwrap(profile.outputs?.first) + XCTAssertEqual(output.packet(active:true),[0xB2,9,1]) + XCTAssertEqual(output.packet(active:false),[0xB2,9,100]) + } + @MainActor func testMixtrackXMLUsesNativeAdapterAndSendsActualLEDOutput() async throws { + let profile = try XCTUnwrap(DJXMLProfile.bundled.first{$0.name == "Numark Mixtrack GO"}) + XCTAssertEqual(profile.adapter,"Mixtrack Go") + XCTAssertNotNil(DJXMLProfile.match("Numark MixTrack Go Bluetooth",in:[profile])) + var client = MIDIClientRef(0),source = MIDIEndpointRef(0),destination = MIDIEndpointRef(0) + XCTAssertEqual(MIDIClientCreateWithBlock("KJMix LED test" as CFString,&client,nil),noErr) + defer { if source != 0 { MIDIEndpointDispose(source) };if destination != 0 { MIDIEndpointDispose(destination) };MIDIClientDispose(client) } + let name = "KJMix LED \(UUID())" + var received = [UInt8]() + XCTAssertEqual(MIDIDestinationCreateWithBlock(client,name as CFString,&destination) { packets,_ in + var bytes = [UInt8]() + var packet = UnsafeRawPointer(packets).advanced(by:MemoryLayout.offset(of:\.packet)!).assumingMemoryBound(to:MIDIPacket.self) + for _ in 0...offset(of:\.data)!) + bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length)));packet = UnsafePointer(MIDIPacketNext(packet)) + } + let captured = bytes;Task { @MainActor in received += captured } + },noErr) + XCTAssertEqual(MIDISourceCreate(client,name as CFString,&source),noErr) + let suite = "KJMix.LED.\(UUID())",defaults = try XCTUnwrap(UserDefaults(suiteName:suite)) + defer { defaults.removePersistentDomain(forName:suite) } + let midi = DJMIDI(defaults:defaults,catalog:[]);midi.refreshSources() + let input = try XCTUnwrap(midi.sources.first{$0.endpoint == source});midi.select(input.id);midi.applyXML(profile) + XCTAssertTrue(midi.mixtrackEnabled) + var actions = [String]();midi.onAction = { action,_,_ in actions.append(action) } + midi.receive([0x95,0,127,0x95,0x17,127]);XCTAssertTrue(actions.contains("B Loop 8")) + midi.updateFeedback(aPlaying:true,bPlaying:false,sync:true,aLoaded:true,bLoaded:true,cue:"B",aCues:[true,false,false,false]) + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertTrue((0..