broken changes
This commit is contained in:
+238
-39
@@ -169,6 +169,9 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.maintainSync() }
|
||||
}.store(in:&syncObservers)
|
||||
Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).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()
|
||||
@@ -186,7 +189,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
func importFiles(_ urls: [URL],loadInto: String? = nil,overridePlaying: Bool = false) {
|
||||
guard !importing,!urls.isEmpty else { return }
|
||||
importing = true;importProgress = .init()
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media")
|
||||
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),replacedSong = loadInto.flatMap{channel($0).song?.id}
|
||||
importTask = Task.detached(priority:.utility) { [weak self] in
|
||||
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
|
||||
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
|
||||
@@ -205,7 +208,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
guard let self else { return };self.importing = false;self.importTask = nil;self.importProgress = result
|
||||
self.perform { try self.refresh() }
|
||||
if let failure = result.failure { self.error = failure }
|
||||
else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying) }
|
||||
else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +248,16 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
catch { if let prior = try? device(old) { try? a.player.routeOutput(prior);try? b.player.routeOutput(prior) };throw error }
|
||||
}
|
||||
}
|
||||
func stopPreview() { preview.stop();previewDeck = nil }
|
||||
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.") }
|
||||
stopPreview();var audio = song;audio.cdg = nil
|
||||
try preview.load(audio,startPlaying:false);try preview.routeOutput(output);preview.toggle();previewDeck = "Library"
|
||||
}
|
||||
}
|
||||
func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() }
|
||||
func startPreview(_ deck: DJChannel) {
|
||||
if previewDeck == deck.letter { stopPreview();return }
|
||||
perform {
|
||||
@@ -257,7 +269,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
try preview.load(audio,startPlaying:false)
|
||||
try preview.routeOutput(output)
|
||||
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
|
||||
preview.seek(deck.player.time);preview.toggle();previewDeck = deck.letter;updateControllerFeedback()
|
||||
status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position"
|
||||
}
|
||||
}
|
||||
@@ -284,19 +296,23 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
func toggleLoop(_ deck: DJChannel) {
|
||||
guard deck.player.canJog else { status = "Load a track first.";return }
|
||||
guard deck.player.loopEnabled || deck.player.time < deck.player.duration-0.1 else { status = "Move the playhead before the end of the track to set a loop.";return }
|
||||
guard deck.player.loopEnabled || deck.bpmConfirmed else { status = "Waiting for BPM analysis. Enter or tap the track BPM, or define a loop with IN and OUT.";return }
|
||||
deck.beatLoop();status = deck.player.loopEnabled ? "Deck \(deck.letter) · \(deck.loopBeats)-beat loop active" : "Deck \(deck.letter) · Loop off"
|
||||
}
|
||||
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) }
|
||||
func hotCue(_ index: Int,deck: DJChannel) {
|
||||
let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil
|
||||
deck.player.hotCue(index)
|
||||
if existing,settings.autoPlayHotCue,!deck.player.playing { deck.player.toggle() }
|
||||
}
|
||||
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 == "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
|
||||
switch parts[1] {
|
||||
@@ -304,6 +320,11 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
case "Stop": p.stopDeck()
|
||||
case "Cue": p.returnToCue()
|
||||
case "Set cue": p.setCue()
|
||||
case "Cue button": if p.playing { p.returnToCue() } else { p.setCue() }
|
||||
case "Cue play": p.returnToCue();p.toggle()
|
||||
case "Loop off": p.clearLoop()
|
||||
case "Loop 1","Loop 2","Loop 4","Loop 8": d.loopBeats = Int(parts[1].split(separator:" ").last!) ?? 8;toggleLoop(d)
|
||||
case "Clear cue 1","Clear cue 2","Clear cue 3","Clear cue 4": p.hotCue((Int(parts[1].suffix(1)) ?? 1)-1,clear:true)
|
||||
case "Sync": sync(d)
|
||||
case "Loop": toggleLoop(d)
|
||||
case "Headphones": startPreview(d)
|
||||
@@ -315,6 +336,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
case "Filter": p.filter = value*2-1
|
||||
case "Level": d.level = value
|
||||
case "Tempo": if syncActive,d.letter == "B" { a.player.tempo = b.bpm*settings.tempo(at:value)/a.bpm;maintainSync() } else { p.tempo = settings.tempo(at:value) }
|
||||
case "Jog ticks": p.jog(value*2/242)
|
||||
case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2)
|
||||
case "Hot cue 1": hotCue(0,deck:d)
|
||||
case "Hot cue 2": hotCue(1,deck:d)
|
||||
@@ -359,6 +381,9 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
@Published var sources = [Source]()
|
||||
@Published private(set) var sourceID: Int32 = 0
|
||||
@Published var mappings = [DJMIDIMapping]()
|
||||
@Published private(set) var profiles = [String:String]()
|
||||
private var mixtrack = MixtrackGoProtocol()
|
||||
private var outputPort = MIDIPortRef(0),outputDestination = MIDIEndpointRef(0)
|
||||
@Published var learning: String?
|
||||
@Published var learnMode = "Absolute"
|
||||
@Published var status = "Choose a MIDI source; learn each control. No assumed factory mapping."
|
||||
@@ -368,10 +393,12 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
private let defaults: UserDefaults
|
||||
init(defaults: UserDefaults = .standard,connect: Bool = true) {
|
||||
self.defaults = defaults
|
||||
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 }
|
||||
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)
|
||||
let input = MIDIInputPortCreateWithBlock(client,"DJ controls" as CFString,&port) { [weak self] list,_ in
|
||||
var bytes = [UInt8]()
|
||||
// Walk the original variable-length packet list, never a copied 256-byte tuple.
|
||||
@@ -394,13 +421,21 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&name)
|
||||
return Source(id:uid,endpoint:endpoint,name:name?.takeRetainedValue() as String? ?? "MIDI source")
|
||||
}
|
||||
if connected != 0,!sources.contains(where:{$0.endpoint == connected}) { select(0);status = "Controller disconnected; choose a source after reconnecting." }
|
||||
if connected != 0,!sources.contains(where:{$0.endpoint == connected}) {
|
||||
disconnect();status = "Controller disconnected · waiting to reconnect"
|
||||
}
|
||||
if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber,
|
||||
sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) }
|
||||
}
|
||||
private func disconnect() {
|
||||
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0
|
||||
}
|
||||
func select(_ id: Int32) {
|
||||
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil
|
||||
disconnect()
|
||||
if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");return }
|
||||
guard let source = sources.first(where:{$0.id == id}) else { return }
|
||||
let result = MIDIPortConnectSource(port,source.endpoint,nil)
|
||||
if result == noErr { connected = source.endpoint;sourceID = id;status = "Connected: \(source.name)" }
|
||||
if result == noErr { connected = source.endpoint;sourceID = id;defaults.set(Int(id),forKey:"DJ.MIDISource.v1");status = "Connected: \(source.name)";mixtrack = .init();selectFeedbackOutput(source.name) }
|
||||
else { status = "Could not connect MIDI source (\(result))" }
|
||||
}
|
||||
func receive(_ bytes: [UInt8]) {
|
||||
@@ -412,15 +447,55 @@ 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) {
|
||||
for event in events {
|
||||
if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) }
|
||||
else { onAction?(event.action,event.value,event.relative) }
|
||||
}
|
||||
onAction?("Controller feedback",0,false)
|
||||
continue
|
||||
}
|
||||
guard let map = mappings.first(where:{$0.source == sourceID && $0.status == message.status && $0.number == message.data1}) else { continue }
|
||||
let continuous = ["Gain","High","Mid","Low","Filter","Level","Tempo","Jog"].contains(map.action.split(separator:" ",maxSplits:1).last.map(String.init) ?? "") || ["Crossfader","Master"].contains(map.action)
|
||||
let continuous = ["Gain","High","Mid","Low","Filter","Level","Tempo","Jog"].contains(map.action.split(separator:" ",maxSplits:1).last.map(String.init) ?? "") || ["Crossfader","Master","Cue volume","Browse"].contains(map.action)
|
||||
if !continuous && message.data2 == 0 { continue }
|
||||
onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute")
|
||||
}
|
||||
}
|
||||
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" }
|
||||
func installMixtrackGo() {
|
||||
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])
|
||||
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.removeValue(forKey:String(sourceID));defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) {
|
||||
guard mixtrackEnabled else { return }
|
||||
sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue))
|
||||
}
|
||||
private func sendBytes(_ bytes: [UInt8]) {
|
||||
guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return }
|
||||
var list = MIDIPacketList()
|
||||
withUnsafeMutablePointer(to:&list) { pointer in
|
||||
let packet = MIDIPacketListInit(pointer)
|
||||
let added = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,$0.count,$0.baseAddress!) }
|
||||
if added != nil { MIDISend(outputPort,outputDestination,pointer) }
|
||||
}
|
||||
}
|
||||
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 client != 0 { MIDIClientDispose(client) } }
|
||||
deinit { if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };if client != 0 { MIDIClientDispose(client) } }
|
||||
}
|
||||
|
||||
private enum DJStyle {
|
||||
@@ -476,7 +551,7 @@ struct DJConsole: View {
|
||||
.preferredColorScheme(.dark)
|
||||
.alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in
|
||||
Button("Cancel",role:.cancel) { system.pendingReplacement = nil }
|
||||
Button("Replace & Load",role:.destructive) { system.confirmReplacement() }
|
||||
Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) }
|
||||
} message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") }
|
||||
.sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) }
|
||||
.sheet(isPresented:$settingsModal) { StudioSettingsDialog(model:model,scope:"DJ") }
|
||||
@@ -560,8 +635,16 @@ struct DJConsole: View {
|
||||
private func loadSelected(_ deck: String) { if let selectedSong { system.load(selectedSong,into:deck) } else { system.status = "Select a DJ library track, then Load \(deck), or drag a DJ library track onto the deck." } }
|
||||
private func refreshPlaylist() { playlistIDs = playlist.flatMap{try? system.library?.playlistSongIDs($0)} ?? [] }
|
||||
private func connectMIDI() {
|
||||
let selected = selectedSong
|
||||
system.midi.onAction = { [weak system] name,value,relative in system?.action(name,value:value,relative:relative,selected:selected) }
|
||||
system.midi.onAction = { [weak system] name,value,relative in
|
||||
guard let system else { return }
|
||||
if name == "Browse" {
|
||||
let rows = songs;guard !rows.isEmpty else { return }
|
||||
let current = rows.firstIndex(where:{$0.id == selected}) ?? (value > 0 ? -1 : rows.count)
|
||||
let index = min(rows.count-1,max(0,current+(value >= 0 ? 1 : -1)))
|
||||
selected = rows[index].id;page = index/50
|
||||
} else if name == "Preview selected",let selectedSong { system.previewLibrary(selectedSong) }
|
||||
else { system.action(name,value:value,relative:relative,selected:selectedSong) }
|
||||
}
|
||||
}
|
||||
private func importMedia() {
|
||||
let panel = NSOpenPanel();panel.title = "Import DJ Music · Files or Folders";panel.canChooseFiles = true;panel.canChooseDirectories = true;panel.allowsMultipleSelection = true
|
||||
@@ -600,7 +683,7 @@ private struct DJDeckSurfaces {
|
||||
}
|
||||
}
|
||||
|
||||
private struct DJKnob: View {
|
||||
struct DJKnob: View {
|
||||
let label: String
|
||||
@Binding var value: Double
|
||||
let range: ClosedRange<Double>
|
||||
@@ -616,10 +699,12 @@ private struct DJKnob: View {
|
||||
Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90))
|
||||
Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270))
|
||||
}.frame(width:29,height:29).contentShape(Circle())
|
||||
.highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil })
|
||||
.simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil})
|
||||
Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary)
|
||||
}.frame(minWidth:36).help("\(label): \(format(value)). Drag vertically; double-click to reset.")
|
||||
}.frame(minWidth:36).contentShape(Rectangle())
|
||||
.highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil })
|
||||
.accessibilityAction(named:Text("Reset to default")) { value = reset;initial = nil }
|
||||
.help("\(label): \(format(value)). Drag vertically; double-click to reset.")
|
||||
.accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value))
|
||||
.accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) }
|
||||
}
|
||||
@@ -655,6 +740,7 @@ private struct DJPlatter: View {
|
||||
private struct DJWaveform: View {
|
||||
@ObservedObject var player: Player
|
||||
let color: Color
|
||||
var bpm: Double?
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Canvas { context,size in
|
||||
@@ -665,9 +751,8 @@ private struct DJWaveform: View {
|
||||
let index = min(values.count-1,i*values.count/count),height = max(1,Double(values[index])*size.height)
|
||||
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.4,height:height)),with:.color(color.opacity(Double(i)/Double(count) < player.time/max(0.001,player.duration) ? 0.4 : 0.9)))
|
||||
}
|
||||
if player.loopEnabled,let start = player.loopIn,let end = player.loopOut,player.duration > 0 {
|
||||
context.fill(Path(CGRect(x:start/player.duration*size.width,y:0,width:(end-start)/player.duration*size.width,height:size.height)),with:.color(.green.opacity(0.18)))
|
||||
}
|
||||
DJWaveMarkers.draw(context:&context,size:size,start:0,span:player.duration,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
|
||||
|
||||
let x = player.time/max(0.001,player.duration)*size.width
|
||||
context.fill(Path(CGRect(x:x,y:0,width:1.5,height:size.height)),with:.color(.white))
|
||||
}.background(.black.opacity(0.3),in:RoundedRectangle(cornerRadius:3))
|
||||
@@ -687,7 +772,8 @@ 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) } }
|
||||
Text("Numark DJ2GO / DJ2GO2 / DJ2GO2 Touch use USB MIDI. “Mix Go” is not a confirmed model name. Select the connected source and learn its controls; no factory note numbers are assumed.").font(.caption).foregroundStyle(.secondary)
|
||||
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() } } }
|
||||
Text("Mixtrack Go preset uses the supplied Mixxx protocol mapping, including 14-bit tempo and hot-cue/loop pad modes. Other controllers can use MIDI Learn.").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Picker("Action",selection:$action) { ForEach(actions,id:\.self) { Text($0).tag($0) } }
|
||||
Picker("Mode",selection:$midi.learnMode) { Text("Absolute").tag("Absolute");Text("Relative signed").tag("Relative signed");Text("Relative offset").tag("Relative offset") }.frame(width:160)
|
||||
@@ -707,7 +793,7 @@ private struct DJMappingSheet: View {
|
||||
}
|
||||
}
|
||||
}.frame(height:200)
|
||||
Text("Mappings save per MIDI source ID. Use relative mode only for Jog encoders. Supported: note buttons and 7-bit CCs. Load uses the selected DJ library row. Button releases are ignored. Controller LEDs, motor feedback, 14-bit controls, scratching and vendor-specific handshakes are not implemented.").font(.caption).foregroundStyle(.secondary)
|
||||
Text("Mappings save per MIDI source ID. Custom learn supports note buttons and 7-bit CCs; the Mixtrack Go preset also handles its 14-bit tempo. Use relative mode only for Jog encoders. Load uses the selected DJ library row. Button releases are ignored. The Mixtrack Go preset sends transport/sync/mode LED feedback when a matching output is found. Stems, sampler, FX modes and vinyl scratching are not implemented.").font(.caption).foregroundStyle(.secondary)
|
||||
if !embedded { HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } }
|
||||
}.padding(24).frame(width:660)
|
||||
}
|
||||
@@ -779,8 +865,8 @@ struct DJContinuousConsole: View {
|
||||
DJConsoleWing(deck:system.a,player:system.a.player,system:system)
|
||||
Divider()
|
||||
VStack(spacing:1) {
|
||||
DJCentreWaveform(player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings)
|
||||
DJCentreWaveform(player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings)
|
||||
DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings)
|
||||
DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings)
|
||||
}.frame(maxWidth:.infinity).background(.black)
|
||||
Divider()
|
||||
DJConsoleWing(deck:system.b,player:system.b.player,system:system)
|
||||
@@ -821,7 +907,7 @@ private struct DJTrackHeader: View {
|
||||
Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit())
|
||||
Button("Load",action:load).controlSize(.mini)
|
||||
}
|
||||
DJWaveform(player:player,color:color).frame(height:22)
|
||||
DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22)
|
||||
}
|
||||
if deck.letter == "B" { artwork }
|
||||
}.padding(10).frame(maxWidth:.infinity)
|
||||
@@ -893,9 +979,11 @@ private struct DJConsoleTransport: View {
|
||||
}
|
||||
}
|
||||
private struct DJCentreWaveform: View {
|
||||
@ObservedObject var deck: DJChannel
|
||||
@ObservedObject var player: Player
|
||||
let letter: String
|
||||
let color: Color
|
||||
private var bpm: Double? { deck.bpmConfirmed ? deck.bpm : nil }
|
||||
var settings = DJPreferences()
|
||||
@State private var scrubStart: Double?
|
||||
var body: some View {
|
||||
@@ -919,6 +1007,7 @@ private struct DJCentreWaveform: View {
|
||||
}
|
||||
context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade))
|
||||
}
|
||||
DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil)
|
||||
context.fill(Path(CGRect(x:size.width/2,y:0,width:1.5,height:size.height)),with:.color(.red))
|
||||
}.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) }
|
||||
.contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in if scrubStart == nil { scrubStart = player.time-settings.waveformSeconds/2 };player.seek((scrubStart ?? 0)+Double(value.location.x/max(1,geometry.size.width))*settings.waveformSeconds) }.onEnded { _ in scrubStart = nil })
|
||||
@@ -955,7 +1044,7 @@ struct DJPreferencesPane: View {
|
||||
}.padding(12)
|
||||
}
|
||||
PreferencesSection("Sync Mode") {
|
||||
VStack(alignment:.leading,spacing:10) { LabeledContent("Sync type",value:"Tempo matching");Text("Uses entered or tapped BPM. Automatic beat analysis, beat-grid alignment and persistent sync lock are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12)
|
||||
VStack(alignment:.leading,spacing:10) { LabeledContent("Sync type",value:"Deck A master");Text("Both decks follow deck A’s effective BPM while Sync is active. A second click restores their previous tempos. BPM can be estimated from audio, entered or tapped; estimates may need correction. Beat-grid phase alignment is not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12)
|
||||
}
|
||||
PreferencesSection("Tempo") {
|
||||
HStack(spacing:24) { Picker("Slider range",selection:$system.settings.tempoRange) { Text("±8%").tag(8.0);Text("±16%").tag(16.0);Text("±50%").tag(50.0) }.frame(width:250);Toggle("Invert slider",isOn:$system.settings.invertTempo) }.padding(12)
|
||||
@@ -997,7 +1086,7 @@ struct DJPreferencesPane: View {
|
||||
LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)")
|
||||
Text(system.root.path).font(.callout).textSelection(.enabled)
|
||||
Button("Show DJ library in Finder") { NSWorkspace.shared.open(system.root) }
|
||||
Text("Import from the DJ browser. Files and playlists are stored separately from karaoke. Imports scan subfolders and verify copied audio. Spotify playback, automatic BPM/key analysis and watched-folder imports are not implemented.").font(.caption).foregroundStyle(.secondary)
|
||||
Text("Import from the DJ browser. Files and playlists are stored separately from karaoke. Imports scan subfolders and verify copied audio. BPM is estimated from audio on load; key analysis, Spotify playback and watched-folder imports are not implemented.").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(12)
|
||||
}
|
||||
PreferencesSection("Library Display") {
|
||||
@@ -1063,19 +1152,11 @@ struct DJReplacement: Identifiable {
|
||||
|
||||
@MainActor extension DJSystem {
|
||||
func acceptDrop(_ providers: [NSItemProvider],into letter: String) -> Bool {
|
||||
if let provider = providers.first(where:{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)}) {
|
||||
let files = providers.filter{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)}
|
||||
if !files.isEmpty {
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let url: URL = try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in
|
||||
if let url = item as? URL { continuation.resume(returning:url) }
|
||||
else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil) { continuation.resume(returning:url) }
|
||||
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read the Finder file.")) }
|
||||
}
|
||||
}
|
||||
guard url.isFileURL else { throw LibraryError.message("Drop a local audio file from Finder.") }
|
||||
requestFileLoad([url],into:letter)
|
||||
} catch { self.error = error.localizedDescription }
|
||||
do { let urls = try await DJFinderDrop.urls(files);requestFileLoad(urls,into:letter) }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1095,8 +1176,8 @@ struct DJReplacement: Identifiable {
|
||||
guard !importing else { status = "Wait for the current DJ import to finish.";return }
|
||||
importFiles(urls,loadInto:letter,overridePlaying:override)
|
||||
}
|
||||
func confirmReplacement() {
|
||||
guard let request = pendingReplacement else { return };pendingReplacement = nil
|
||||
func confirmReplacement(_ captured: DJReplacement? = nil) {
|
||||
guard let request = captured ?? pendingReplacement else { return };pendingReplacement = nil
|
||||
if let song = request.song { load(song,into:request.deck,overridePlaying:true) }
|
||||
else { requestFileLoad(request.urls,into:request.deck,override:true) }
|
||||
}
|
||||
@@ -1179,3 +1260,121 @@ private struct DJCueEditor: View {
|
||||
}.padding(24).frame(width:470)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
struct Event { var action: String;var value: Double = 1;var relative = false }
|
||||
var modes = [0,0]
|
||||
var lowEQ = true
|
||||
private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false]
|
||||
static func mappings(source: Int32) -> [DJMIDIMapping] {
|
||||
var result = [DJMIDIMapping]()
|
||||
func add(_ status: UInt8,_ number: UInt8,_ action: String,_ mode: String = "Absolute") { result.append(.init(source:source,status:status,number:number,action:action,mode:mode)) }
|
||||
add(0xBF,0x0A,"Master");add(0xBF,0x0C,"Cue volume");add(0xBF,0x08,"Crossfader")
|
||||
add(0xBF,0x00,"Browse","Relative signed");add(0x9F,0x07,"Preview selected")
|
||||
for i in 0..<2 {
|
||||
let d = i == 0 ? "A" : "B",note = UInt8(0x90+i),cc = UInt8(0xB0+i)
|
||||
add(0x9F,UInt8(0x02+i),d+" Load")
|
||||
add(cc,0x16,d+" Level");add(cc,0x06,d+" Jog ticks","Relative signed")
|
||||
add(note,0x1B,d+" Headphones");add(note,0x00,d+" Play");add(note,0x01,d+" Cue button");add(note,0x02,d+" Sync")
|
||||
add(note,0x03,d+" Sync");add(note,0x04,d+" Cue play");add(note,0x05,d+" Cue play")
|
||||
}
|
||||
return result
|
||||
}
|
||||
mutating func consume(_ message: DJMIDIMessage) -> [Event]? {
|
||||
let status = message.status,number = message.data1,value = message.data2
|
||||
if status == 0x9F,number == 0x48 { lowEQ.toggle();return [.init(action:"Notice: Knobs now control "+(lowEQ ? "Low EQ" : "Filter"))] }
|
||||
if [UInt8(0xB0),0xB1].contains(status) {
|
||||
let index = Int(status & 1),deck = index == 0 ? "A" : "B"
|
||||
if number == 0x09 || number == 0x29 {
|
||||
if number == 0x09 { msb[index] = Int(value);gotMSB[index] = true }
|
||||
else { lsb[index] = Int(value);gotLSB[index] = true }
|
||||
guard gotMSB[index],gotLSB[index] else { return [] }
|
||||
let raw = (msb[index] << 7) | lsb[index],signed = max(-1,min(1,Double(raw-8192)/8191))
|
||||
return [.init(action:deck+" Tempo",value:0.5+signed/2)]
|
||||
}
|
||||
if number == 0x1A { return [.init(action:deck+(lowEQ ? " Low" : " Filter"),value:Double(value)/127)] }
|
||||
}
|
||||
if [UInt8(0x94),0x95].contains(status) {
|
||||
let index = Int(status & 1),deck = index == 0 ? "A" : "B"
|
||||
if number == 0 { modes[index] = (modes[index]+1)%5;return [.init(action:"Notice: "+deck+" pad mode · "+["Hot cues","Loops","FX (unsupported)","Sampler (unsupported)","Stems (unsupported)"][modes[index]])] }
|
||||
let shifted = (0x1C...0x1F).contains(number),first: UInt8 = shifted ? 0x1C : 0x14
|
||||
guard (first...(first+3)).contains(number) else { return nil }
|
||||
let pad = Int(number-first)
|
||||
if modes[index] == 0 { return [.init(action:deck+(shifted ? " Clear cue " : " Hot cue ")+String(pad+1))] }
|
||||
if modes[index] == 1 { return [.init(action:deck+(shifted ? " Loop off" : " Loop "+String(1 << pad)))] }
|
||||
return [.init(action:"Notice: This pad mode requires an FX, sampler or stems engine that is not implemented.")]
|
||||
}
|
||||
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] {
|
||||
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 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]
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
||||
enum DJFinderDrop {
|
||||
static func urls(_ providers: [NSItemProvider]) async throws -> [URL] {
|
||||
var urls = [URL]()
|
||||
for provider in providers {
|
||||
let url: URL = try await withCheckedThrowingContinuation { continuation in
|
||||
provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in
|
||||
if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url);return }
|
||||
provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in
|
||||
if let url = item as? URL,url.isFileURL { continuation.resume(returning:url) }
|
||||
else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url) }
|
||||
else { continuation.resume(throwing:error ?? LibraryError.message("Could not read an absolute local file URL from Finder. Drop the audio file itself or import it with Import Files / Folder.")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
urls.append(url)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
}
|
||||
|
||||
// BPM-relative guides start at source time zero; these are not analysed downbeats.
|
||||
enum DJWaveMarkers {
|
||||
static func beats(start: Double,span: Double,bpm: Double?,width: Double) -> [Int] {
|
||||
guard let bpm,bpm.isFinite,bpm > 0,start.isFinite,span.isFinite,span > 0,width > 0 else { return [] }
|
||||
let interval = 60/bpm,first = max(0,Int(ceil(start/interval))),last = Int(floor((start+span)/interval))
|
||||
guard last >= first else { return [] }
|
||||
let strideBy = max(1,Int(ceil(span/interval/max(1,width/5))))
|
||||
return Array(stride(from:first,through:last,by:strideBy))
|
||||
}
|
||||
static func draw(context: inout GraphicsContext,size: CGSize,start: Double,span: Double,bpm: Double?,loop: ClosedRange<Double>?) {
|
||||
guard span > 0 else { return }
|
||||
if let bpm {
|
||||
for beat in beats(start:start,span:span,bpm:bpm,width:size.width) {
|
||||
let x = (Double(beat)*60/bpm-start)/span*size.width
|
||||
context.fill(Path(CGRect(x:x,y:0,width:beat % 4 == 0 ? 1.2 : 0.6,height:size.height)),with:.color(.white.opacity(beat % 4 == 0 ? 0.5 : 0.22)))
|
||||
}
|
||||
}
|
||||
if let loop {
|
||||
let left = max(0,(loop.lowerBound-start)/span*size.width),right = min(size.width,(loop.upperBound-start)/span*size.width)
|
||||
if right > left {
|
||||
let rect = CGRect(x:left,y:0,width:right-left,height:size.height)
|
||||
context.fill(Path(rect),with:.color(.green.opacity(0.22)))
|
||||
context.stroke(Path(rect.insetBy(dx:0.8,dy:0.8)),with:.color(.green),lineWidth:1.5)
|
||||
}
|
||||
for (time,label) in [(loop.lowerBound,"IN"),(loop.upperBound,"OUT")] {
|
||||
let x = (time-start)/span*size.width
|
||||
if x >= 0,x <= size.width {
|
||||
context.fill(Path(CGRect(x:x,y:0,width:2,height:size.height)),with:.color(.green))
|
||||
context.draw(Text(label).font(.system(size:8,weight:.bold)).foregroundColor(.green),at:CGPoint(x:min(size.width-12,max(12,x)),y:7))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user