broken changes
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor final class DJAutomix: ObservableObject {
|
||||
struct Entry: Codable, Identifiable, Equatable {
|
||||
var id = UUID()
|
||||
var song: Int64
|
||||
var title: String
|
||||
var artist: String
|
||||
}
|
||||
private struct Saved: Codable { var entries: [Entry];var seconds: Double;var matchTempo: Bool }
|
||||
private struct Prepared { var entry: Entry;var deck: String }
|
||||
private struct Fade { var from: String;var to: String;var start: Double;var duration: Double }
|
||||
@Published private(set) var entries = [Entry]()
|
||||
@Published private(set) var enabled = false
|
||||
@Published private(set) var activeDeck: String?
|
||||
@Published private(set) var progress: Double?
|
||||
@Published private(set) var message = "Add tracks from the DJ library"
|
||||
@Published var seconds: Double = 8 { didSet { save() } }
|
||||
@Published var matchTempo = false { didSet { save() } }
|
||||
private weak var system: DJSystem?
|
||||
private let file: URL
|
||||
private var prepared: Prepared?
|
||||
private var fade: Fade?
|
||||
private var task: Task<Void,Never>?
|
||||
private var expectedFader: Double = 0
|
||||
init(system: DJSystem) {
|
||||
self.system = system;file = system.root.appendingPathComponent("DJQueue.json")
|
||||
if let data = try? Data(contentsOf:file),let saved = try? JSONDecoder().decode(Saved.self,from:data) {
|
||||
entries = Array(saved.entries.prefix(10000));seconds = saved.seconds.isFinite ? min(30,max(0,saved.seconds)) : 8;matchTempo = saved.matchTempo
|
||||
}
|
||||
}
|
||||
deinit { task?.cancel() }
|
||||
private func save() {
|
||||
do { try JSONEncoder().encode(Saved(entries:entries,seconds:seconds,matchTempo:matchTempo)).write(to:file,options:.atomic) }
|
||||
catch { message = "Queue could not be saved: \(error.localizedDescription)" }
|
||||
}
|
||||
func enqueue(_ songs: [Song]) {
|
||||
entries += songs.prefix(max(0,10000-entries.count)).map { Entry(song:$0.id,title:$0.title,artist:$0.artist) };save()
|
||||
}
|
||||
func remove(_ id: UUID) { entries.removeAll{$0.id == id};save() }
|
||||
func clear() { entries = [];save() }
|
||||
func shuffle() { entries.shuffle();save() }
|
||||
func move(_ id: UUID,by offset: Int) {
|
||||
guard let index = entries.firstIndex(where:{$0.id == id}) else { return }
|
||||
let target = min(entries.count-1,max(0,index+offset));guard target != index else { return }
|
||||
let entry = entries.remove(at:index);entries.insert(entry,at:target);save()
|
||||
}
|
||||
func stop(_ reason: String = "Automix off · playback remains under manual control") {
|
||||
enabled = false;task?.cancel();task = nil;fade = nil;prepared = nil;progress = nil;activeDeck = nil;message = reason
|
||||
}
|
||||
func manualTakeover() { if enabled { stop("Automix off · manual control") } }
|
||||
func start(runTimer: Bool = true) {
|
||||
guard let system,!enabled else { return }
|
||||
guard system.routing.mode == "Internal" else { message = "Automix requires Internal mixer mode";return }
|
||||
let playing = [system.a,system.b].filter{$0.player.playing}
|
||||
guard playing.count <= 1 else { message = "Pause one deck before starting Automix";return }
|
||||
system.disableSync()
|
||||
if let deck = playing.first { activeDeck = deck.letter }
|
||||
else {
|
||||
guard !entries.isEmpty else { message = "Add tracks to the DJ queue first";return }
|
||||
activeDeck = "A";setFader(0)
|
||||
guard let next = prepare(on:"A") else { activeDeck = nil;return }
|
||||
entries.removeAll{$0.id == next.entry.id};save();system.a.player.toggle()
|
||||
}
|
||||
guard let activeDeck else { return }
|
||||
setFader(activeDeck == "A" ? 0 : 1);enabled = true;message = "Automix on · deck \(activeDeck)"
|
||||
_ = prepare(on:activeDeck == "A" ? "B" : "A")
|
||||
if runTimer {
|
||||
task = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do { try await Task.sleep(nanoseconds:50_000_000) } catch { return }
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private func setFader(_ value: Double) { expectedFader = value;system?.crossfader = value }
|
||||
private func prepare(on letter: String) -> Prepared? {
|
||||
guard let system,!system.channel(letter).player.playing else { return nil }
|
||||
while let entry = entries.first {
|
||||
guard let song = system.songs.first(where:{$0.id == entry.song}) else { entries.removeFirst();save();message = "Skipped unavailable track: \(entry.title)";continue }
|
||||
system.load(song,into:letter,automated:true)
|
||||
guard system.channel(letter).player.currentSongID == song.id else {
|
||||
entries.removeFirst();save();message = "Skipped unreadable track: \(entry.title)";continue
|
||||
}
|
||||
let next = Prepared(entry:entry,deck:letter);prepared = next;return next
|
||||
}
|
||||
prepared = nil;return nil
|
||||
}
|
||||
func mixNow(now: Double = ProcessInfo.processInfo.systemUptime) {
|
||||
guard enabled,fade == nil,let system,let activeDeck else { return }
|
||||
if prepared?.entry.id != entries.first?.id { prepared = nil }
|
||||
if prepared == nil { _ = prepare(on:activeDeck == "A" ? "B" : "A") }
|
||||
guard let next = prepared else { message = "No next track queued";return }
|
||||
let outgoing = system.channel(activeDeck),incoming = system.channel(next.deck)
|
||||
guard incoming.player.currentSongID == next.entry.song,!incoming.player.playing else { stop("Automix off · deck changed manually");return }
|
||||
if matchTempo,outgoing.bpmConfirmed,incoming.bpmConfirmed {
|
||||
let rate = outgoing.bpm*outgoing.player.tempo/incoming.bpm,range = system.settings.tempoRange/100
|
||||
if rate.isFinite,(1-range...1+range).contains(rate) { incoming.player.tempo = rate }
|
||||
}
|
||||
let remaining = max(0,(outgoing.player.duration-outgoing.player.time)/max(0.5,outgoing.player.tempo))
|
||||
let duration = min(max(0,seconds),remaining,incoming.player.duration/max(0.5,incoming.player.tempo)/2)
|
||||
entries.removeAll{$0.id == next.entry.id};save();prepared = nil
|
||||
incoming.player.toggle()
|
||||
fade = Fade(from:activeDeck,to:next.deck,start:now,duration:duration);progress = 0
|
||||
message = "Mixing \(activeDeck) → \(next.deck)"
|
||||
if duration == 0 { finishFade() }
|
||||
}
|
||||
func tick(now: Double = ProcessInfo.processInfo.systemUptime) {
|
||||
guard enabled,let system,let activeDeck else { return }
|
||||
guard system.routing.mode == "Internal" else { stop("Automix off · external routing selected");return }
|
||||
guard abs(system.crossfader-expectedFader) < 0.03 else { stop("Automix off · crossfader moved manually");return }
|
||||
if let fade {
|
||||
guard system.channel(fade.to).player.playing else { stop("Automix off · incoming deck paused");return }
|
||||
let amount = min(1,max(0,(now-fade.start)/max(0.001,fade.duration)))
|
||||
progress = amount;setFader(fade.from == "A" ? amount : 1-amount)
|
||||
if amount >= 1 { finishFade() };return
|
||||
}
|
||||
let player = system.channel(activeDeck).player
|
||||
if player.loopEnabled { message = "Loop active · exit the loop to continue Automix";return }
|
||||
if !player.playing {
|
||||
if player.time >= player.duration-0.05 {
|
||||
if entries.isEmpty { stop("Queue finished");return }
|
||||
mixNow(now:now)
|
||||
} else { stop("Automix off · active deck paused") }
|
||||
return
|
||||
}
|
||||
if prepared?.entry.id != entries.first?.id { prepared = nil }
|
||||
let idle = activeDeck == "A" ? "B" : "A"
|
||||
if prepared == nil { _ = prepare(on:idle) }
|
||||
if let prepared,system.channel(prepared.deck).player.playing { stop("Automix off · second deck started manually");return }
|
||||
let remaining = max(0,(player.duration-player.time)/max(0.5,player.tempo))
|
||||
message = entries.isEmpty ? "Last track · queue finishes at the end" : "Next transition in \(Int(max(0,remaining-max(0,seconds)))) s"
|
||||
if !entries.isEmpty,remaining <= max(0.05,seconds) { mixNow(now:now) }
|
||||
}
|
||||
private func finishFade() {
|
||||
guard let fade,let system else { return }
|
||||
setFader(fade.to == "A" ? 0 : 1);system.channel(fade.from).player.stopDeck()
|
||||
activeDeck = fade.to;self.fade = nil;progress = nil
|
||||
message = "Automix on · deck \(fade.to)"
|
||||
_ = prepare(on:fade.from)
|
||||
}
|
||||
}
|
||||
|
||||
struct DJQueuePane: View {
|
||||
@ObservedObject var system: DJSystem
|
||||
@ObservedObject var automix: DJAutomix
|
||||
var body: some View {
|
||||
VStack(alignment:.leading,spacing:8) {
|
||||
HStack { Label("DJ Queue",systemImage:"text.line.first.and.arrowtriangle.forward").font(.headline);Spacer();Text("\(automix.entries.count)").foregroundStyle(.secondary) }
|
||||
HStack {
|
||||
Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor)
|
||||
Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil)
|
||||
}.controlSize(.small)
|
||||
HStack {
|
||||
Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1);Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34)
|
||||
}.font(.caption)
|
||||
Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption)
|
||||
if let progress = automix.progress { ProgressView(value:progress) }
|
||||
List {
|
||||
ForEach(automix.entries) { entry in
|
||||
HStack(spacing:5) {
|
||||
VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) }
|
||||
Spacer(minLength:0)
|
||||
Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier")
|
||||
Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later")
|
||||
Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue")
|
||||
}.font(.caption).buttonStyle(.borderless)
|
||||
}
|
||||
if automix.entries.isEmpty { Text("Add songs with + or drag them here from the DJ library.").font(.caption).foregroundStyle(.secondary) }
|
||||
}.listStyle(.inset).scrollContentBackground(.hidden)
|
||||
.onDrop(of:["public.text"],isTargeted:nil) { providers in
|
||||
guard let provider = providers.first else { return false }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let reference = try await SongDragReference.load(provider)
|
||||
guard reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return }
|
||||
automix.enqueue([song])
|
||||
} catch { system.error = error.localizedDescription }
|
||||
}
|
||||
return true
|
||||
}
|
||||
HStack { Button("Shuffle") { automix.shuffle() };Button("Clear") { automix.clear() };Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty)
|
||||
Text(automix.message).font(.caption2).foregroundStyle(.secondary).lineLimit(2)
|
||||
}.padding(12).background(Color.white.opacity(0.035),in:RoundedRectangle(cornerRadius:6))
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,13 @@ struct DJPreferences: Codable, Equatable {
|
||||
private var analysisTask: Task<Double?,Never>?
|
||||
@Published var gain: Double = 0 { didSet { player.inputGain = gain } }
|
||||
@Published var level: Double = 0.8 { didSet { updateLevel() } }
|
||||
@Published var loopBeats = 8
|
||||
@Published var loopBeats = 8 {
|
||||
didSet {
|
||||
guard oldValue != loopBeats,loopBeats > 0,player.loopEnabled,let start = player.loopIn,let end = player.loopOut else { return }
|
||||
let length = bpmConfirmed ? Double(loopBeats)*60/bpm : (end-start)*Double(loopBeats)/Double(max(1,oldValue))
|
||||
player.defineLoop(start:start,end:min(player.duration,start+length))
|
||||
}
|
||||
}
|
||||
var master: Double = 0.8 { didSet { updateLevel() } }
|
||||
var crossWeight: Double = 1 { didSet { updateLevel() } }
|
||||
private var taps = [Double]()
|
||||
@@ -123,16 +129,15 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) }
|
||||
guard result == noErr else { return [] }
|
||||
return ids.compactMap { id in
|
||||
var streams = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreams,mScope:kAudioDevicePropertyScopeOutput,mElement:kAudioObjectPropertyElementMain)
|
||||
var count: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id,&streams,0,nil,&count) == noErr,count > 0 else { return nil }
|
||||
let channels = channelCount(id)
|
||||
guard channels > 0 else { return nil }
|
||||
func string(_ selector: AudioObjectPropertySelector) -> String? {
|
||||
var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
|
||||
var text: Unmanaged<CFString>?;var bytes = UInt32(MemoryLayout<CFString?>.size)
|
||||
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
|
||||
return text?.takeRetainedValue() as String?
|
||||
}
|
||||
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channelCount(id))
|
||||
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channels)
|
||||
}
|
||||
}
|
||||
static func defaultOutput() -> AudioDeviceID? {
|
||||
@@ -146,6 +151,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
let a = DJChannel("A"),b = DJChannel("B")
|
||||
let preview = Player(persistsMix:false)
|
||||
let midi = DJMIDI()
|
||||
lazy var automix = DJAutomix(system:self)
|
||||
@Published var crossfader: Double = 0.5 { didSet { applyMix() } }
|
||||
@Published var master: Double = 0.8 { didSet { applyMix() } }
|
||||
@Published private(set) var syncActive = false
|
||||
@@ -234,10 +240,11 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
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 = 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) {
|
||||
func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) {
|
||||
perform {
|
||||
let deck = channel(letter)
|
||||
if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return }
|
||||
if !automated { automix.manualTakeover() }
|
||||
if syncActive { disableSync() }
|
||||
if previewDeck == letter { stopPreview() }
|
||||
try deck.load(song)
|
||||
@@ -246,7 +253,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
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() }
|
||||
if settings.autoPlayOnLoad && !automated { deck.player.toggle() }
|
||||
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
|
||||
}
|
||||
}
|
||||
@@ -263,7 +270,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
let previous = routing
|
||||
perform {
|
||||
try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
|
||||
stopPreview()
|
||||
automix.manualTakeover();stopPreview()
|
||||
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)
|
||||
@@ -341,11 +348,12 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) {
|
||||
if name == "Controller feedback" { updateControllerFeedback();return }
|
||||
if name == "Crossfader" { crossfader = min(1,max(0,value));return }
|
||||
if name == "Crossfader" { automix.manualTakeover();crossfader = min(1,max(0,value));return }
|
||||
if name == "Master" { master = min(1,max(0,value));return }
|
||||
if name == "Cue volume" { cueLevel = min(1,max(0,value));return }
|
||||
let parts = name.split(separator:" ",maxSplits:1).map(String.init)
|
||||
guard parts.count == 2 else { return };let d = channel(parts[0]),p = d.player
|
||||
if ["Play","Stop","Cue","Cue button","Cue play","Sync","Load","Tempo","Tempo XML","Jog","Jog ticks"].contains(parts[1]) { automix.manualTakeover() }
|
||||
switch parts[1] {
|
||||
case "Play": p.toggle()
|
||||
case "Stop": p.stopDeck()
|
||||
@@ -359,7 +367,6 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
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 }
|
||||
@@ -430,6 +437,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 closing = false
|
||||
private var terminationObserver: AnyCancellable?
|
||||
private var initializationTask: Task<Void,Never>?
|
||||
private var lastFeedback = [UInt8]()
|
||||
@Published var learning: String?
|
||||
@@ -448,6 +457,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
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 }
|
||||
terminationObserver = NotificationCenter.default.publisher(for:NSApplication.willTerminateNotification).sink { [weak self] _ in self?.shutdown() }
|
||||
let result = MIDIClientCreateWithBlock("KJMix DJ" as CFString,&client) { [weak self] _ in Task { @MainActor in self?.refreshSources() } }
|
||||
guard result == noErr else { status = "MIDI unavailable (\(result))";return }
|
||||
MIDIOutputPortCreate(client,"DJ feedback" as CFString,&outputPort)
|
||||
@@ -489,6 +499,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = []
|
||||
}
|
||||
func select(_ id: Int32) {
|
||||
guard !closing else { return }
|
||||
disconnect()
|
||||
if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");autoDetect = false;return }
|
||||
guard let source = sources.first(where:{$0.id == id}) else { return }
|
||||
@@ -498,10 +509,12 @@ 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() }
|
||||
} else if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) }
|
||||
else if profiles[String(id)] == "Mixtrack Go" { configureMixtrackMappings();onAction?("Controller feedback",0,false) }
|
||||
}
|
||||
else { status = "Could not connect MIDI source (\(result))" }
|
||||
}
|
||||
func receive(_ bytes: [UInt8]) {
|
||||
guard !closing else { return }
|
||||
for message in decoder.feed(bytes) {
|
||||
let type = message.status & 0xF0
|
||||
if let profile = activeXML,profile.adapter == nil,learning == nil {
|
||||
@@ -539,14 +552,24 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" }
|
||||
func installMixtrackGo() {
|
||||
private func configureMixtrackMappings() {
|
||||
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()
|
||||
initializeMixtrack()
|
||||
status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pad modes. Hardware testing required."
|
||||
let ids = Set(preset.map(\.id))
|
||||
mappings.removeAll { ids.contains($0.id) };mappings += preset;mixtrack = .init();save();initializeMixtrack()
|
||||
}
|
||||
func installMixtrackGo() {
|
||||
profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1")
|
||||
configureMixtrackMappings()
|
||||
status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pads and LED feedback"
|
||||
onAction?("Controller feedback",0,false)
|
||||
}
|
||||
func shutdown() { resetController();closing = true }
|
||||
func resetController() {
|
||||
initializationTask?.cancel();initializationTask = nil;lastFeedback = [];learning = nil
|
||||
if mixtrackEnabled { sendBytes(MixtrackGoProtocol.shutdownBytes) }
|
||||
else if let outputs = activeXML?.outputs { for output in outputs { sendBytes([output.status,output.number,0]) } }
|
||||
mixtrack = .init()
|
||||
}
|
||||
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
|
||||
@@ -565,9 +588,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
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()
|
||||
initializeMixtrack()
|
||||
configureMixtrackMappings()
|
||||
}
|
||||
onAction?("Controller feedback",0,false)
|
||||
status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report"
|
||||
@@ -594,6 +615,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
}
|
||||
}
|
||||
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 !closing else { return }
|
||||
if !mixtrackEnabled {
|
||||
guard let outputs = activeXML?.outputs else { return }
|
||||
for output in outputs {
|
||||
@@ -671,7 +693,10 @@ struct DJConsole: View {
|
||||
VStack(spacing:10) {
|
||||
header
|
||||
DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")})
|
||||
browser.frame(height:expanded ? 300 : 130)
|
||||
HStack(alignment:.top,spacing:12) {
|
||||
browser.frame(maxWidth:.infinity).frame(height:expanded ? 330 : 220)
|
||||
DJQueuePane(system:system,automix:system.automix).frame(width:310,height:expanded ? 330 : 220)
|
||||
}
|
||||
if let progress = system.importProgress {
|
||||
HStack {
|
||||
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
|
||||
@@ -725,6 +750,7 @@ struct DJConsole: View {
|
||||
}
|
||||
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
|
||||
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
|
||||
Button { system.automix.enqueue(songs) } label: { Image(systemName:"text.badge.plus") }.help("Add this library or playlist to the DJ queue").disabled(songs.isEmpty)
|
||||
Button("Import Files / Folder…") { importMedia() }
|
||||
Spacer(minLength:0)
|
||||
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0)
|
||||
@@ -738,6 +764,7 @@ struct DJConsole: View {
|
||||
Text(song.title).font(.system(size:system.settings.libraryFont,weight:.medium)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading)
|
||||
Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading)
|
||||
Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35)
|
||||
Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue")
|
||||
Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A")
|
||||
Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B")
|
||||
}.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini)
|
||||
@@ -749,6 +776,7 @@ struct DJConsole: View {
|
||||
}
|
||||
}
|
||||
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
|
||||
Button("Add to DJ Queue") { system.automix.enqueue([song]) }
|
||||
Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } }
|
||||
if let playlist {
|
||||
Button("Move Earlier") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:-1) };refreshPlaylist() }
|
||||
@@ -931,7 +959,7 @@ private struct DJMappingSheet: View {
|
||||
VStack(alignment:.leading,spacing:14) {
|
||||
HStack { Text("MIDI Controller Mapping").font(.title2.bold());Spacer();Button("Refresh") { midi.refreshSources() } }
|
||||
Picker("MIDI input",selection:Binding(get:{midi.sourceID},set:{midi.select($0)})) { Text("Not connected").tag(Int32(0));ForEach(midi.sources) { Text($0.name).tag($0.id) } }
|
||||
HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green);Button("Use Custom Learn") { midi.useCustomProfile() } } }
|
||||
HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green) } }
|
||||
Toggle("Automatically detect and reconnect supported controllers",isOn:$midi.autoDetect)
|
||||
VStack(alignment:.leading,spacing:8) {
|
||||
HStack { Text("XML Controller Profiles").font(.headline);Spacer();Button("Import XML…") { midi.importXMLFiles() } }
|
||||
@@ -1442,6 +1470,15 @@ private struct DJCueEditor: View {
|
||||
/// Protocol facts adapted from josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx.
|
||||
/// XML is authoritative for the right Mode button (95 00); the JS table has a conflicting entry.
|
||||
struct MixtrackGoProtocol {
|
||||
static var shutdownBytes: [UInt8] {
|
||||
var bytes: [UInt8] = [0x9E,0x7F,0]
|
||||
for i in 0..<2 {
|
||||
for note in [0,1,2,3,4,5,0x46,0x48,0x54] { bytes += [UInt8(0x90+i),UInt8(note),0] }
|
||||
bytes += [UInt8(0x80+i),0x1B,0,0x9F,UInt8(2+i),0,0x9F,UInt8(0x48+i),0]
|
||||
for note in Array(1...4)+Array(0x14...0x17)+Array(0x1C...0x1F) { bytes += [UInt8(0x94+i),UInt8(note),0] }
|
||||
}
|
||||
return bytes+[0x9F,0x46,0,0x9F,0x47,0]
|
||||
}
|
||||
struct Event { var action: String;var value: Double = 1;var relative = false }
|
||||
var modes = [0,0]
|
||||
var lowEQ = false
|
||||
|
||||
Reference in New Issue
Block a user