broken changes
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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<Void,Never>?
|
||||
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..<MIDIGetNumberOfDestinations() {
|
||||
let endpoint = MIDIGetDestination(i);var result: Unmanaged<CFString>?
|
||||
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
|
||||
|
||||
@@ -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<String>(),blocked = Set<String>()
|
||||
var bindings = [DJXMLBinding](),outputs = [DJXMLOutput](),issues = Set<String>(),blocked = Set<String>()
|
||||
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 {
|
||||
|
||||
@@ -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<MIDIPacketList>.offset(of:\.packet)!).assumingMemoryBound(to:MIDIPacket.self)
|
||||
for _ in 0..<packets.pointee.numPackets {
|
||||
let data = UnsafeRawPointer(packet).advanced(by:MemoryLayout<MIDIPacket>.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..<max(0,received.count-2)).contains{Array(received[$0..<($0+3)]) == [0x90,0,127]})
|
||||
XCTAssertTrue((0..<max(0,received.count-2)).contains{Array(received[$0..<($0+3)]) == [0x94,0x14,127]})
|
||||
XCTAssertTrue((0..<max(0,received.count-6)).contains{Array(received[$0..<($0+7)]) == [0xF0,0,0x20,0x7F,3,1,0xF7]})
|
||||
let initial = received.count
|
||||
try await Task.sleep(nanoseconds:2_100_000_000)
|
||||
XCTAssertGreaterThan(received.count,initial,"LED state is resent after the startup light show ends")
|
||||
}
|
||||
func testDjayXMLLoadsAllSuppliedProfilesAndReportsUnsupportedActions() throws {
|
||||
XCTAssertEqual(DJXMLProfile.bundled.count,210)
|
||||
let dj2go = try XCTUnwrap(DJXMLProfile.bundled.first{$0.name == "Numark DJ2GO"})
|
||||
|
||||
@@ -167,4 +167,4 @@ Key and Tempo controls beside the main transport adjust the loaded performance i
|
||||
|
||||
### Optional DJ console development
|
||||
|
||||
The pending `DJSystem` / `DJConsole` integration is retained behind `KJMIX_DJ_CONSOLE`. Default Debug and Release builds compile the working karaoke interface. Add the missing console implementation to the target before enabling that Active Compilation Condition.
|
||||
The main window provides Karaoke and DJ Studio tabs; both retain playback while switching. DJ Studio has its own library, MIDI profiles and saved multichannel audio routing. See [DJ workstation notes](docs/DJ_WORKSTATION.md) for supported controls, XML profile compatibility and hardware verification.
|
||||
|
||||
+18
-10
@@ -1,6 +1,6 @@
|
||||
# DJ workstation — feature/dj_workstation
|
||||
|
||||
The main window now opens with the original karaoke workspace above a resizable DJ workspace. The toolbar’s Show/Hide DJ button returns the entire window to karaoke without stopping any deck. Existing singer, venue, rotation, video, background music, library and recursive directory import features remain available. Background music is a compact single-row strip above karaoke transport. In split view, song settings open from the Song Settings button; karaoke-only view keeps the full settings strip. DJ controls have mirrored tone panels, consistent padding and a separate browser area; the DJ workspace scrolls vertically when the split is too short to show everything.
|
||||
The main window has two tabs: **Karaoke** and **DJ Studio**. Both workspaces stay mounted, preserving playback, MIDI handling and selections when switching. The DJ tab has a larger independent browser. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope.
|
||||
|
||||
## Using the decks
|
||||
|
||||
@@ -20,28 +20,36 @@ The DJ browser supports title/artist/album search and paginates in groups of 50.
|
||||
|
||||
## Audio routing
|
||||
|
||||
The routing sheet enumerates CoreAudio output devices. Both DJ decks can route to one selected master device; karaoke can independently route to another device. Defaults use the system output. Selections are session-only. Background music retains its existing output.
|
||||
DJ Settings → Devices lists CoreAudio outputs including the Mac’s speakers and the controller’s internal sound card, with automatic device-list refresh on connection changes. Choose a stereo channel pair next to each device, then Apply; Revert restores the applied configuration. Routing persists in `DJ/DJAudioRouting.json`.
|
||||
|
||||
Headphone preview is real, independent playback on another selected output device. It starts at the selected deck’s position with that deck’s current tempo/EQ/filter, before its gain, level and crossfader. Later deck changes are not followed; restart preview to refresh. One deck can be previewed at a time. Preview is disabled until an output is explicitly selected, and rejects the current master device to avoid sending cue audio publicly.
|
||||
- **Internal:** both decks use Main Output. Pre-Cueing can use another device or a non-overlapping pair on the same multichannel interface. On Mixtrack Go, choose Numark MixTrack Go Audio, main ch 1–2 and pre-cue ch 3–4.
|
||||
- **External:** decks A and B have independent device/pair selectors. The software crossfader is bypassed; use an external mixer. Decks cannot share the same device/pair, and cue cannot overlap either public output.
|
||||
- Headphone preview is independent playback starting from the selected deck’s position and current tempo/EQ/filter. Later changes are not followed; restart preview to refresh. One deck can be previewed at a time. Cue overlap is rejected before public audio is sent. Karaoke has its own routing; background music follows the existing output.
|
||||
|
||||
**Not implemented:** master/cue channel-pair selection on a single multichannel device, phase-locked PFL, aggregate-device creation, input/microphone routing, automatic device failover, and calibrated round-trip latency. Devices use their default stereo output pair. A Numark’s master and headphone sockets on the same CoreAudio device therefore cannot yet be independently addressed. Hardware routing needs verification on the intended interface.
|
||||
Actual AUHAL channel-map readback on the attached four-channel Numark interface verified `[0,1,-1,-1]` for main 1–2 and `[-1,-1,0,1]` for cue 3–4. This is an API-level check; audible socket isolation and physical monitoring still need listening verification. Implementation follows Apple’s [channel-map property](https://developer.apple.com/documentation/audiotoolbox/kaudiooutputunitproperty_channelmap).
|
||||
|
||||
**Not implemented:** booth/microphone/recording input, phase-locked PFL, aggregate-device creation, automatic failover and calibrated round-trip latency.
|
||||
|
||||
## Controllers
|
||||
|
||||
The confirmed model is **Numark Mixtrack Go**. Its preset was independently implemented from the protocol addresses in the supplied [Mixxx mapping](https://github.com/josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx). [Numark’s product page](https://www.numark.com/new/mixtrack-go/) documents USB class compliance and Bluetooth MIDI.
|
||||
|
||||
In DJ Settings → MIDI, select the controller input and choose **Apply Mixtrack Go Preset**. The profile persists per source ID. Supported controls include transport, cue, shared Sync, deck load, 14-bit tempo, relative jog, channel faders, crossfader, master/cue volume, headphone preview, library browsing and preview, low/filter mode, and four hot-cue/loop pads with shifted clear/off actions. A standard identity request and transport, sync, cue and mode feedback are sent when a matching MIDI output is found. Deck B pad mode follows the XML address 95/00; the upstream JavaScript table contains a conflicting address.
|
||||
The 210 supplied `.djayMidiMapping` files are included as XML property lists under `KJamsClone/MIDIMappings`. In DJ Settings → MIDI, search/select a profile, view its Compatibility Report, then Apply XML Profile. Import XML accepts additional `.xml`, `.plist` and `.djayMidiMapping` files. Inputs and applied profiles are saved per source ID.
|
||||
|
||||
Custom MIDI Learn remains available for note buttons and 7-bit CC controls. Select an action, click Learn Next Control, then operate the control. Learning does not trigger playback, releases are ignored, and continuous CC zero remains valid. The selected MIDI input is remembered across launches and automatically reconnected when it becomes available; choosing Not connected clears that saved selection. Presets and learned mappings remain saved per source.
|
||||
Automatic detection is enabled by default. It matches exact endpoint names and aliases from the supplied files, chooses a unique supported source, and restores a saved input after relaunch/reconnection. Ambiguous matches require manual selection. Not connected disables automatic detection until re-enabled. Custom MIDI Learn remains available.
|
||||
|
||||
**Hardware limitations:** no physical Mixtrack Go has been tested. LED feedback, USB/Bluetooth delivery and audio sockets need on-device verification. Its same-device master 1–2/headphones 3–4 routing is not implemented; cue currently requires a separate output device. Stems, sampler, FX pad modes, touch scratching, HID, motor feedback and controller certification are not implemented. Unsupported pad modes report their limitation rather than triggering unrelated actions.
|
||||
The generic adapter handles standard note/CC and 14-bit pitch-bend bindings for available two-deck transport, cue/hot cues 1–4, loops, EQ/filter/gain, channel volume, crossfader, master/cue level, load and browse actions. It respects inversion, signed/offset relative encoders and soft takeover for supported absolute controls. Common play/cue/sync/headphone/loop/hot-cue LED outputs support explicit addresses, min/max values and inversion. Conflicting, conditional, modifier, HID or unsupported assignments are disabled and reported. djay custom processors, arbitrary predicates, displays, four-deck operation, proprietary scripts and rotary acceleration curves are not reproduced.
|
||||
|
||||
**Mixtrack Go** uses a dedicated native adapter when its supplied XML profile is selected or detected. This enables its 14-bit split-CC tempo, relative jog, channel faders, filter/low switch, browsing/loading, transport/shared Sync, headphone controls, hot-cue/loop pad modes and corresponding LED feedback. Apply Mixtrack Go Preset remains available as a direct alternative. Initialization sends identity and controller-state requests and refreshes LEDs after two seconds, because the startup demo can clear early LED state. Deck B pad mode uses XML address 95/00. Supported transport, mode, load, shifted transport and pad LEDs are driven from app state.
|
||||
|
||||
**Limitations:** stems, sampler, FX modes, touch scratching, HID, motor feedback and controller certification are not implemented. Unsupported pad modes report the limitation. Tests exercised actual CoreMIDI output to a receiving virtual destination, including delayed LED refresh. The attached Numark MIDI endpoints and audio device were discovered, and its channel maps were checked; physical knob/button behaviour, visible LED appearance, Bluetooth delivery and audible socket isolation still need hands-on verification.
|
||||
|
||||
## Verification — 5 November 2026
|
||||
|
||||
- Debug build compiled for arm64 and x86_64 without distribution signing; the macOS XCTest suite passed **128 tests with zero failures** on Apple silicon. Intel runtime playback was not exercised.
|
||||
- Debug build compiled for arm64 and x86_64 without distribution signing; the macOS XCTest suite passed **137 tests with zero failures** on Apple silicon. Intel runtime playback was not exercised.
|
||||
- Tests cover actual looping playback including end-of-file, editable cues, linked Sync and tempo restoration, audio-derived BPM, Finder replacement confirmation, JPG artwork, real knob double-click resets, native fader orientation/actions, preset addresses, 14-bit tempo, pad modes, MIDI releases/persistence and virtual CoreMIDI input delivery.
|
||||
- Existing karaoke, recursive CDG/audio import, independent DJ library/playlists, rotation, background handoff, media-copy repair and audio processing checks passed.
|
||||
- Native console and settings captures use actual NSHostingView windows. Physical controller controls, LEDs and separate physical audio outputs remain untested. Loops/jogs use seek scheduling rather than sample-accurate scratching.
|
||||
- Native console and settings captures use actual NSHostingView windows. Virtual CoreMIDI output and attached-interface channel maps were tested; physical button/LED appearance and audible output isolation remain to be verified. Loops/jogs use seek scheduling rather than sample-accurate scratching.
|
||||
|
||||
## Import repair and settings modal
|
||||
|
||||
@@ -61,4 +69,4 @@ Settings now use an icon-category toolbar with separate Karaoke and DJ selectors
|
||||
|
||||
No nonfunctional switches were added for DVS, booth/microphone routing, Neural Mix, automix, recording, Spotify or cloud sync. Sync uses estimated or entered/tapped BPM for tempo matching; both overview and scrolling waveforms show BPM-relative beat guides, with stronger marks every four beats. Guides begin at source time zero and are not analysed downbeats. Active loops are highlighted green with IN/OUT boundary lines in both views.
|
||||
|
||||
Validation: full suite **128 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp.
|
||||
Validation: full suite **137 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp.
|
||||
|
||||
Reference in New Issue
Block a user