diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index 6cb78be..192f248 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + B30000000000000000000001 /* DJAudioRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002; }; B20000000000000000000001 /* DJXMLMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002; }; B20000000000000000000003 /* MIDIMappings in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004; }; B10000000000000000000001 /* DJWorkstation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002; }; @@ -36,6 +37,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = ""; }; B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = ""; }; B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = ""; }; B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = ""; }; @@ -73,6 +75,7 @@ 000000000000000000000001 /* KJMixProApp.swift */, B10000000000000000000002 /* DJWorkstation.swift */, B20000000000000000000002 /* DJXMLMapping.swift */, + B30000000000000000000002 /* DJAudioRouting.swift */, B20000000000000000000004 /* MIDIMappings */, 000000000000000000000002 /* Library.swift */, 000000000000000000000003 /* CDG.swift */, @@ -196,6 +199,7 @@ 000000000000000000000065 /* KJMixProApp.swift in Sources */, B10000000000000000000001 /* DJWorkstation.swift in Sources */, B20000000000000000000001 /* DJXMLMapping.swift in Sources */, + B30000000000000000000001 /* DJAudioRouting.swift in Sources */, 000000000000000000000066 /* Library.swift in Sources */, 000000000000000000000067 /* CDG.swift in Sources */, 000000000000000000000068 /* Importer.swift in Sources */, diff --git a/KJamsClone/DJAudioRouting.swift b/KJamsClone/DJAudioRouting.swift new file mode 100644 index 0000000..e4b1926 --- /dev/null +++ b/KJamsClone/DJAudioRouting.swift @@ -0,0 +1,89 @@ +import SwiftUI +import CoreAudio + +struct DJAudioRoute: Codable, Equatable { + var uid = "" + var firstChannel = 0 +} +struct DJAudioRouting: Codable, Equatable { + var mode = "Internal" + var main = DJAudioRoute() + var deckA = DJAudioRoute() + var deckB = DJAudioRoute() + var cue = DJAudioRoute() + func route(_ letter: String) -> DJAudioRoute { mode == "External" ? (letter == "A" ? deckA : deckB) : main } + 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 + } + func validate(devices: [DJAudioDevice],defaultID: AudioDeviceID?) throws { + func resolved(_ route: DJAudioRoute) throws -> DJAudioDevice { + guard let device = devices.first(where:{route.uid.isEmpty ? $0.id == defaultID : $0.uid == route.uid}) else { throw LibraryError.message("An audio output is unavailable. Reconnect it or choose another device.") } + _ = try Self.channelMap(channels:device.outputChannels,first:route.firstChannel);return device + } + let outputs = mode == "External" ? [deckA,deckB] : [main] + for route in outputs { _ = try resolved(route) } + if mode == "External",try resolved(deckA).id == resolved(deckB).id,deckA.firstChannel == deckB.firstChannel { + throw LibraryError.message("External mixer decks need separate output devices or channel pairs.") + } + if !cue.uid.isEmpty { + let cueDevice = try resolved(cue) + for route in outputs where try resolved(route).id == cueDevice.id && route.firstChannel == cue.firstChannel { + throw LibraryError.message("Pre-cueing overlaps a public output. Choose another channel pair or device.") + } + } + } +} + +@MainActor final class DJAudioDeviceMonitor { + private var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) + private var block: AudioObjectPropertyListenerBlock? + init(_ changed: @escaping @MainActor () -> Void) { + let listener: AudioObjectPropertyListenerBlock = { _,_ in Task { @MainActor in changed() } } + block = listener;AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject),&address,.main,listener) + } + deinit { if let block { AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject),&address,.main,block) } } +} + +struct DJAudioDevicesPane: View { + @ObservedObject var system: DJSystem + @State private var draft = DJAudioRouting() + var body: some View { + VStack(alignment:.leading,spacing:26) { + 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() } + 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) + HStack { Text("Cue Volume:").frame(width:130,alignment:.trailing);Slider(value:$system.cueLevel,in:0...1).frame(maxWidth:320);Spacer() } + }.padding(.vertical,22) + } + 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) } + 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) + } + 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 } + } + private func row(_ label: String,route: Binding,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)) + 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)})) { + Text(optional ? "None" : "System default").tag("") + ForEach(system.devices) { Text($0.name).tag($0.uid) } + 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) + } + } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index f26f300..87211fc 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -106,6 +106,15 @@ struct DJAudioDevice: Identifiable, Equatable { var id: AudioDeviceID 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 size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id,&address,0,nil,&size) == noErr,size >= MemoryLayout.size else { return 0 } + let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout.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] { var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var size: UInt32 = 0 @@ -123,7 +132,7 @@ struct DJAudioDevice: Identifiable, Equatable { 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") + return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channelCount(id)) } } static func defaultOutput() -> AudioDeviceID? { @@ -152,6 +161,8 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var devices = [DJAudioDevice]() @Published var masterUID = "" @Published var cueUID = "" + @Published private(set) var routing = DJAudioRouting() + private var audioDeviceMonitor: DJAudioDeviceMonitor? @Published var settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } } @Published var status = "Load music into A or B. Karaoke keeps its own mixer and output." @Published var error: String? @@ -172,12 +183,17 @@ struct DJAudioDevice: Identifiable, Equatable { Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).sink { [weak self] _ in Task { @MainActor [weak self] in self?.updateControllerFeedback() } }.store(in:&syncObservers) + Publishers.CombineLatest4(a.player.$hotCues,b.player.$hotCues,a.player.$loopEnabled,b.player.$loopEnabled).sink { [weak self] _ in + Task { @MainActor [weak self] in self?.updateControllerFeedback() } + }.store(in:&syncObservers) perform { try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh() if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized } + if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) } applyMix() } + audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() } } private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } } func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] } @@ -216,7 +232,7 @@ struct DJAudioDevice: Identifiable, Equatable { deinit { importTask?.cancel() } func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } - func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = weights.0;b.crossWeight = weights.1 } + func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 } func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b } func load(_ song: Song,into letter: String,overridePlaying: Bool = false) { perform { @@ -225,8 +241,9 @@ struct DJAudioDevice: Identifiable, Equatable { if syncActive { disableSync() } if previewDeck == letter { stopPreview() } try deck.load(song) - if !masterUID.isEmpty { - do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error } + let route = routing.route(letter) + if !route.uid.isEmpty || route.firstChannel != 0 || routing.mode == "External" { + do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error } } if settings.resetEQOnLoad { deck.player.resetTone() } if settings.autoPlayOnLoad { deck.player.toggle() } @@ -240,21 +257,35 @@ struct DJAudioDevice: Identifiable, Equatable { return output.id } func routeMaster(_ uid: String) { - let old = masterUID + var next = routing;next.main.uid = uid;next.main.firstChannel = 0;applyRouting(next) + } + func applyRouting(_ next: DJAudioRouting) { + let previous = routing perform { - let id = try device(uid) + try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) stopPreview() - do { try a.player.routeOutput(id);try b.player.routeOutput(id);masterUID = uid } - catch { if let prior = try? device(old) { try? a.player.routeOutput(prior);try? b.player.routeOutput(prior) };throw error } + do { + for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } + try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic) + routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix() + status = "Audio routing applied · \(next.mode) mixer" + } catch { + for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } } + throw error + } } } + private func validateCue() throws { + var config = routing;config.cue.uid = cueUID + try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) + } func previewLibrary(_ song: Song) { perform { guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") } let output = try device(cueUID) - guard output != (try device(masterUID)) else { throw LibraryError.message("Library preview needs a separate headphone device.") } + try validateCue() stopPreview();var audio = song;audio.cdg = nil - try preview.load(audio,startPlaying:false);try preview.routeOutput(output);preview.toggle();previewDeck = "Library" + try preview.load(audio,startPlaying:false);try preview.routeOutput(output,firstChannel:routing.cue.firstChannel);preview.toggle();previewDeck = "Library" } } func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() } @@ -264,10 +295,10 @@ struct DJAudioDevice: Identifiable, Equatable { guard !cueUID.isEmpty else { throw LibraryError.message("Choose a separate headphone output in Audio Routing first.") } guard let song = deck.song else { throw LibraryError.message("Load a track first.") } let output = try device(cueUID) - guard output != (try device(masterUID)) else { throw LibraryError.message("Headphone preview requires a different output device from the master. Multichannel pairs on one device are not yet supported.") } + try validateCue() stopPreview();var audio = song;audio.cdg = nil try preview.load(audio,startPlaying:false) - try preview.routeOutput(output) + try preview.routeOutput(output,firstChannel:routing.cue.firstChannel) preview.tempo = deck.player.tempo;preview.lowGain = deck.player.lowGain;preview.midGain = deck.player.midGain;preview.highGain = deck.player.highGain;preview.filter = deck.player.filter preview.seek(deck.player.time);preview.toggle();previewDeck = deck.letter;updateControllerFeedback() status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position" @@ -302,7 +333,7 @@ struct DJAudioDevice: Identifiable, Equatable { } func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true } func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false } - private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck) } + private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck,aCues:a.player.hotCues.map{$0 != nil},bCues:b.player.hotCues.map{$0 != nil},aLoop:a.player.loopEnabled,bLoop:b.player.loopEnabled) } func hotCue(_ index: Int,deck: DJChannel) { let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil deck.player.hotCue(index) @@ -325,8 +356,10 @@ struct DJAudioDevice: Identifiable, Equatable { case "Loop off": p.clearLoop() case "Loop in": p.markLoopIn() case "Loop out": p.markLoopOut() - case "Loop double": d.loopBeats = min(32,d.loopBeats*2) - case "Loop half": d.loopBeats = max(1,d.loopBeats/2) + case "Loop double","Loop half": + let factor = parts[1] == "Loop double" ? 2.0 : 0.5 + d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor))) + if p.loopEnabled,let start = p.loopIn,let end = p.loopOut { p.defineLoop(start:start,end:min(p.duration,start+(end-start)*factor)) } case "Tempo XML": let rate = 1+(value*2-1)*settings.tempoRange/100 if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate } @@ -404,11 +437,12 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { private var client = MIDIClientRef(0),port = MIDIPortRef(0),connected = MIDIEndpointRef(0) private var decoder = DJMIDIDecoder() private let defaults: UserDefaults - init(defaults: UserDefaults = .standard,connect: Bool = true) { - self.defaults = defaults + init(defaults: UserDefaults = .standard,connect: Bool = true,catalog: [DJXMLProfile] = DJXMLProfile.bundled) { + self.defaults = defaults;self.xmlCatalog = catalog autoDetect = defaults.object(forKey:"DJ.MIDIAutoDetect.v1") as? Bool ?? true if let data = defaults.data(forKey:"DJ.XMLAssignments.v1"),let saved = try? JSONDecoder().decode([String:DJXMLProfile].self,from:data) { xmlAssignments = saved } if let data = defaults.data(forKey:"DJ.XMLCatalog.v1"),let saved = try? JSONDecoder().decode([DJXMLProfile].self,from:data) { mergeXML(saved) } + for (source,profile) in xmlAssignments { if let updated = xmlCatalog.first(where:{$0.id == profile.id}) { xmlAssignments[source] = updated } } profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:] if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved } guard connect else { return } @@ -443,7 +477,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber, sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) } if connected == 0,autoDetect,defaults.object(forKey:"DJ.MIDISource.v1") == nil { - let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil || DJXMLProfile.normalized(source.name) == "numark mixtrack go" } + let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil } if matches.count == 1,let match = matches.first { select(match.id) } else if matches.count > 1 { status = "Multiple supported controllers detected · choose a MIDI input" } } @@ -461,13 +495,14 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { 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 { status = "Could not connect MIDI source (\(result))" } } func receive(_ bytes: [UInt8]) { for message in decoder.feed(bytes) { let type = message.status & 0xF0 - if let profile = activeXML,learning == nil { + if let profile = activeXML,profile.adapter == nil,learning == nil { if type == 0x80 || (type == 0x90 && message.data2 == 0) { continue } for binding in profile.bindings where binding.matches(message) { let value = binding.value(message) @@ -487,7 +522,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { mappings.removeAll{$0.id == mapping.id || ($0.source == sourceID && $0.action == action)};mappings.append(mapping);save();learning = nil;status = "Learned \(action) · channel \((message.status & 0x0F)+1), control \(message.data1)" continue } - if profiles[String(sourceID)] == "Mixtrack Go",let events = mixtrack.consume(message) { + if mixtrackEnabled,let events = mixtrack.consume(message) { for event in events { if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) } else { onAction?(event.action,event.value,event.relative) } @@ -501,7 +536,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute") } } - var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" } + var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" } func installMixtrackGo() { let preset = MixtrackGoProtocol.mappings(source:sourceID) let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset @@ -527,6 +562,11 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1") if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") } pickupValues = [:];pickedUp = [];learning = nil + 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) + } status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report" } private func selectFeedbackOutput(_ name: String) { @@ -538,9 +578,9 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if candidate == name.lowercased() || (candidate.contains("mixtrack") && candidate.contains("go")) { outputDestination = endpoint;break } } } - func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) { + 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)) + sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop)) } private func sendBytes(_ bytes: [UInt8]) { guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return } @@ -1163,17 +1203,7 @@ struct DJPreferencesPane: View { } } } - private var devices: some View { - PreferencesSection("DJ Audio Devices") { - VStack(alignment:.leading,spacing:18) { - Picker("Main output",selection:Binding(get:{system.masterUID},set:{system.routeMaster($0)})) { Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } } - Picker("Headphone preview",selection:Binding(get:{system.cueUID},set:{system.stopPreview();system.cueUID = $0})) { Text("None").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } } - HStack { Text("Preview volume");Slider(value:$system.cueLevel,in:0...1) } - Button("Refresh devices") { system.refreshDevices() } - Text("Independent headphone playback requires a different device from main output. Each device uses its default stereo pair. Multichannel master/cue pairs, booth output, microphone input and external mixer routing are not implemented. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary) - }.padding(12) - }.onAppear { system.refreshDevices() } - } + private var devices: some View { DJAudioDevicesPane(system:system) } private var sound: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Mixer") { @@ -1378,7 +1408,7 @@ private struct DJCueEditor: View { struct MixtrackGoProtocol { struct Event { var action: String;var value: Double = 1;var relative = false } var modes = [0,0] - var lowEQ = true + var lowEQ = false private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false] static func mappings(source: Int32) -> [DJMIDIMapping] { var result = [DJMIDIMapping]() @@ -1421,12 +1451,17 @@ struct MixtrackGoProtocol { if [UInt8(0x90),0x91].contains(status),[UInt8(0x46),0x47,0x48,0x49].contains(number) { return [.init(action:"Notice: Acapella/instrumental stems are not implemented.")] } return nil } - func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) -> [UInt8] { + func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) -> [UInt8] { var bytes = [UInt8]() for i in 0..<2 { 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] + 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 + bytes += [pad,UInt8(0x14+index),on ? 127 : 1] + } for mode in 0..<4 { let on = modes[i] == 0 ? mode == 0 : modes[i] == 1 ? mode == 1 : modes[i] == 2 ? (mode == 1 || mode == 2) : modes[i] == 3 ? mode == 2 : mode == 3 bytes += [pad,UInt8(mode+1),on ? 127 : 1] diff --git a/KJamsClone/DJXMLMapping.swift b/KJamsClone/DJXMLMapping.swift index dae3d25..53e8d17 100644 --- a/KJamsClone/DJXMLMapping.swift +++ b/KJamsClone/DJXMLMapping.swift @@ -27,14 +27,17 @@ struct DJXMLProfile: Codable, Identifiable { var bindings: [DJXMLBinding] var issues: [String] var total: Int + var aliases: [String]? + var adapter: String? 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" : "") + "BPM sync, seek-based jog and headphones use KJMix Pro behaviour. Relative encoders use signed or offset MIDI values; djay acceleration curves are not reproduced. Hardware has not been certified. Audio routing is configured separately.\n\n" + issues.joined(separator:"\n") } static func parse(_ data: Data,filename: String) throws -> DJXMLProfile { guard data.count <= 8_000_000 else { throw failure("Mapping file exceeds 8 MB.") } guard let root = try PropertyListSerialization.propertyList(from:data,options:[],format:nil) as? [String:Any], - root["schemeVersion"] as? Int == 1 else { throw failure("Expected a djay mapping XML property list with schemeVersion 1.") } + (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() @@ -70,14 +73,20 @@ struct DJXMLProfile: Codable, Identifiable { 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.") } - return .init(id:root["uuid"] as? String ?? filename,name:filename,endpoint:root["endpointName"] as? String ?? filename,bindings:bindings,issues:issues.sorted(),total:controls.filter{$0["headingTitle"] == nil}.count) + let aliases = root["endpointName"] as? [String] ?? [root["endpointName"] as? String ?? filename] + let adapter = root["customClassName"] as? String == "NumarkMixtrackGo" ? "Mixtrack Go" : nil + if adapter != nil { + 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) } static func failure(_ message: String) -> NSError { NSError(domain:"DJ XML Mapping",code:1,userInfo:[NSLocalizedDescriptionKey:message]) } static func normalized(_ name: String) -> String { name.lowercased().split(whereSeparator:{$0.isWhitespace}).joined(separator:" ") } static func match(_ endpoint: String,in profiles: [DJXMLProfile]) -> DJXMLProfile? { - let matches = profiles.filter { !$0.bindings.isEmpty && normalized($0.endpoint) == normalized(endpoint) } + let matches = profiles.filter { !$0.bindings.isEmpty && ($0.aliases ?? [$0.endpoint]).contains { normalized($0) == normalized(endpoint) } } return matches.count == 1 ? matches.first : nil } static func action(_ path: String) -> String? { diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index a4a3395..4d072a3 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -796,9 +796,9 @@ struct LibraryView: View { workspaceLayout .background(StudioTheme.frame) .tint(.accentColor) - .focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty))) - .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true})) - .sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model) } + .focusedSceneValue(\.nameReinterpretation,NameReinterpretationCommands(perform:{beginReinterpretation()},enabled:workspaceTab == "Karaoke" && ["Library","Singer Songs"].contains(source) && (selectedSong != nil || !tableSelection.isEmpty))) + .focusedSceneValue(\.libraryCommands,workspaceTab == "Karaoke" ? LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{studioSettingsModal = true}) : nil) + .sheet(isPresented:$studioSettingsModal) { StudioSettingsDialog(model:model,scope:workspaceTab == "DJ Studio" ? "DJ" : "Karaoke") } .navigationTitle(workspaceTab == "DJ Studio" ? "DJ Studio" : source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer") : source) .navigationSubtitle(model.currentVenueName) .toolbar { diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 5e549a6..3a77b5f 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -92,13 +92,24 @@ import CoreAudio private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") } private var restoringMix = true private var persistsMix = true - func routeOutput(_ device: AudioDeviceID) throws { + func routeOutput(_ device: AudioDeviceID,firstChannel: Int = 0) throws { guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") } let running = engine.isRunning;engine.stop();var value = device let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value))) guard result == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio routing failed (\(result))") } + var map = try DJAudioRouting.channelMap(channels:DJAudioDevice.channelCount(device),first:firstChannel) + let mapResult = map.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) } + guard mapResult == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio channel routing failed (\(mapResult))") } if running { try engine.start() } } + func outputChannelMap() -> [Int32] { + guard let unit = engine.outputNode.audioUnit else { return [] } + var size: UInt32 = 0 + guard AudioUnitGetPropertyInfo(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,&size,nil) == noErr,size > 0 else { return [] } + var values = [Int32](repeating:-1,count:Int(size)/4) + let result = values.withUnsafeMutableBytes { AudioUnitGetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,&size) } + return result == noErr ? values : [] + } struct GlobalMix: Codable, Equatable { var volume: Double; var low: Double; var mid: Double; var high: Double; var filter: Double; var enabled: Bool } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index c8cb8ea..50b993a 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2127,7 +2127,7 @@ extension KJamsCloneTests { let suite = "KJMix.XMLDetect.\(UUID())",defaults = try XCTUnwrap(UserDefaults(suiteName:suite)) defer { defaults.removePersistentDomain(forName:suite) } let profile = try xmlMapping([["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":1]],endpoint:endpoint) - let midi = DJMIDI(defaults:defaults);midi.addXMLProfiles([profile]) + let midi = DJMIDI(defaults:defaults,catalog:[profile]);midi.addXMLProfiles([profile]) var uid: Int32 = 0;MIDIObjectGetIntegerProperty(source,kMIDIPropertyUniqueID,&uid) XCTAssertEqual(midi.sourceID,uid);XCTAssertEqual(midi.activeXML?.id,profile.id) MIDIEndpointDispose(source);source = 0;midi.refreshSources();XCTAssertEqual(midi.sourceID,0) @@ -2139,4 +2139,37 @@ extension KJamsCloneTests { midi.select(0);midi.refreshSources();XCTAssertEqual(midi.sourceID,0);XCTAssertFalse(midi.autoDetect) XCTAssertFalse(DJMIDI(defaults:defaults,connect:false).autoDetect) } + @MainActor func testWorkspaceTabsSwitchWhileBothPlayersContinue() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + defer { model.player.stop();model.dj.a.player.stop() } + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let library = try XCTUnwrap(model.library) + let id = try library.add(title:"Tab Playback",audio:audio.path,cdg:nil) + let song = try XCTUnwrap(try library.songs().first{$0.id == id}) + try model.player.load(song);model.dj.load(song,into:"A");model.dj.a.player.toggle() + let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:1000).preferredColorScheme(.dark)) + host.frame = NSRect(x:0,y:0,width:1320,height:1000) + 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);window.makeKey() + 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}) + 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) + } + try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded() + XCTAssertEqual(tabs.selectedSegment,index) + XCTAssertTrue(model.player.playing);XCTAssertTrue(model.dj.a.player.playing) + XCTAssertEqual(model.player.currentSongID,id);XCTAssertEqual(model.dj.a.player.currentSongID,id) + } + try await selectTab(1) + 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/dj-tab.png")) } + try await selectTab(0) + 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/karaoke-tab.png")) } + } }