broken changes

This commit is contained in:
Mark Gregory
2026-11-05 16:02:42 +00:00
parent d565e40215
commit eb98f97ded
3 changed files with 402 additions and 63 deletions
+238 -39
View File
@@ -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))
}
}
}
}
}
+148 -2
View File
@@ -1703,7 +1703,7 @@ extension KJamsCloneTests {
XCTAssertTrue(system.a.player.audioPlaybackStarted);XCTAssertTrue(system.b.player.audioPlaybackStarted)
XCTAssertGreaterThan(system.a.player.time,0.05);XCTAssertGreaterThan(system.b.player.time,0.55)
system.a.bpm = 120;system.a.bpmConfirmed = true;system.b.bpm = 126;system.b.bpmConfirmed = true
system.sync(system.a);XCTAssertEqual(system.a.player.tempo,1.05,accuracy:0.001)
system.sync(system.a);XCTAssertTrue(system.syncActive);XCTAssertEqual(system.a.player.tempo,1,accuracy:0.001);XCTAssertEqual(system.b.player.tempo,120.0/126,accuracy:0.001)
system.a.player.stopDeck();XCTAssertTrue(system.b.player.playing)
system.startPreview(system.b);XCTAssertNotNil(system.error);XCTAssertNil(system.previewDeck);XCTAssertFalse(system.preview.playing)
}
@@ -1747,6 +1747,7 @@ extension KJamsCloneTests {
try await capturePreferences("DJ","General","/private/tmp/kjmix-dj-settings-general.png")
try await capturePreferences("DJ","Sound","/private/tmp/kjmix-dj-settings-sound.png")
try await capturePreferences("DJ","Appearance","/private/tmp/kjmix-dj-settings-appearance.png")
model.dj.sync(model.dj.a)
let console = NSHostingView(rootView:DJContinuousConsole(system:model.dj,loadA:{},loadB:{}).frame(width:1320,height:390).foregroundStyle(.white).preferredColorScheme(.dark))
console.frame = NSRect(x:0,y:0,width:1320,height:390)
let consoleWindow = NSWindow(contentRect:console.frame,styleMask:[.borderless],backing:.buffered,defer:false)
@@ -1769,6 +1770,11 @@ extension KJamsCloneTests {
let midi = DJMIDI(defaults:defaults);midi.refreshSources()
let input = try XCTUnwrap(midi.sources.first{$0.endpoint == source});midi.select(input.id)
XCTAssertEqual(midi.sourceID,input.id)
XCTAssertEqual(defaults.integer(forKey:"DJ.MIDISource.v1"),Int(input.id))
do {
let restored = DJMIDI(defaults:defaults)
XCTAssertEqual(restored.sourceID,input.id,"A fresh MIDI session restores the saved controller")
}
var actions = [String]();midi.onAction = { action,_,_ in actions.append(action) }
func send(_ packets: [[UInt8]]) throws {
var list = MIDIPacketList()
@@ -1787,6 +1793,8 @@ extension KJamsCloneTests {
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(actions,["A Play"])
midi.select(0)
XCTAssertNil(defaults.object(forKey:"DJ.MIDISource.v1"))
XCTAssertEqual(DJMIDI(defaults:defaults).sourceID,0)
}
}
@@ -1910,7 +1918,7 @@ extension KJamsCloneTests {
let system = DJSystem(storageURL:root);system.master = 0
defer { system.a.player.stop();system.b.player.stop() }
system.load(first,into:"A");system.a.player.toggle()
system.load(second,into:"A");XCTAssertNotNil(system.error);XCTAssertEqual(system.a.song?.id,1)
system.load(second,into:"A");XCTAssertNotNil(system.pendingReplacement);XCTAssertEqual(system.a.song?.id,1);system.pendingReplacement = nil
system.a.player.stopDeck();system.error = nil
system.settings.autoPlayOnLoad = true;system.settings.resetEQOnLoad = true
system.settings.tempoRange = 8;system.settings.crossfaderCurve = "Linear";system.settings.headroomDB = 6
@@ -1923,3 +1931,141 @@ extension KJamsCloneTests {
XCTAssertEqual(restored.settings,system.settings)
}
}
extension KJamsCloneTests {
@MainActor func testLinkedSyncUsesFirstDeckTogglesBothAndRestoresOriginalTempos() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root);system.master = 0
defer { system.a.unload();system.b.unload() }
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = Song(id:1,title:"Sync",audio:file.path,cdg:nil,rating:0,plays:0)
system.load(song,into:"A");system.load(song,into:"B")
system.a.bpm = 128;system.b.bpm = 120;system.a.bpmConfirmed = true;system.b.bpmConfirmed = true
system.a.player.tempo = 1.04;system.b.player.tempo = 0.96
system.sync(system.b)
XCTAssertTrue(system.syncActive);XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001)
XCTAssertEqual(system.b.bpm*system.b.player.tempo,128*1.04,accuracy:0.001)
system.a.player.tempo = 1.02;try await Task.sleep(nanoseconds:40_000_000)
XCTAssertEqual(system.b.bpm*system.b.player.tempo,128*1.02,accuracy:0.001)
system.sync(system.a)
XCTAssertFalse(system.syncActive);XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001);XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001)
}
@MainActor func testDJLoopActuallyRepeatsAndExplicitCueDoesNotMovePlayback() async throws {
let deck = DJChannel("A");defer { deck.unload() };deck.master = 0
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try deck.load(.init(id:1,title:"Loop",audio:file.path,cdg:nil,rating:0,plays:0))
deck.bpm = 120;deck.bpmConfirmed = true;deck.loopBeats = 1
deck.player.seek(0.2);deck.beatLoop();deck.player.toggle()
try await Task.sleep(nanoseconds:900_000_000)
XCTAssertTrue(deck.player.playing);XCTAssertTrue(deck.player.loopEnabled);XCTAssertGreaterThanOrEqual(deck.player.time,0.19);XCTAssertLessThan(deck.player.time,0.8)
deck.beatLoop();XCTAssertFalse(deck.player.loopEnabled)
deck.player.stopDeck();deck.player.setCue(at:1.2345)
XCTAssertEqual(deck.player.cuePosition,1.2345,accuracy:0.0001);XCTAssertEqual(deck.player.time,0,accuracy:0.001)
deck.player.returnToCue();XCTAssertEqual(deck.player.time,1.2345,accuracy:0.001)
deck.player.defineLoop(start:deck.player.duration-0.2,end:deck.player.duration);deck.player.seek(deck.player.duration-0.2);deck.player.toggle()
try await Task.sleep(nanoseconds:500_000_000)
XCTAssertTrue(deck.player.playing,"Loop ending at EOF must restart instead of finishing")
}
func testBPMEstimationRecognisesPulseTrainAndRejectsSilence() throws {
let envelope = (0..<2400).map { $0 % 96 < 4 ? 1.0 : 0.02 }
XCTAssertEqual(try XCTUnwrap(DJBPM.estimate(envelope)),125,accuracy:0.5)
XCTAssertNil(DJBPM.estimate(Array(repeating:0,count:2400)))
XCTAssertNil(DJBPM.estimate(Array(repeating:0.5,count:2400)))
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)),buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:96000))
buffer.frameLength = 96000;let samples = try XCTUnwrap(buffer.floatChannelData)[0]
for i in 0..<96000 { samples[i] = i % 3840 < 40 ? 0.8 : 0 }
let path = root.appendingPathComponent("125 BPM.wav")
do { let output = try AVAudioFile(forWriting:path,settings:format.settings);try output.write(from:buffer) }
XCTAssertEqual(try XCTUnwrap(DJBPM.analyse(path)),125,accuracy:1)
}
@MainActor func testFinderDropWarnsBeforeImportAndAllowsExplicitOverride() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root);system.master = 0;defer { system.a.unload() }
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try system.a.load(.init(id:999,title:"Playing",audio:file.path,cdg:nil,rating:0,plays:0));system.a.player.toggle()
let provider = NSItemProvider()
provider.registerDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier,visibility:.all) { completion in completion(Data(file.absoluteString.utf8),nil);return nil }
XCTAssertTrue(system.acceptDrop([provider],into:"A"))
for _ in 0..<100 { if system.pendingReplacement != nil { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertNotNil(system.pendingReplacement,system.error ?? system.status);XCTAssertTrue(system.a.player.playing);XCTAssertTrue(system.songs.isEmpty)
let captured = try XCTUnwrap(system.pendingReplacement);system.pendingReplacement = nil
system.confirmReplacement(captured)
for _ in 0..<300 { if !system.importing { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertNil(system.error);XCTAssertNil(system.pendingReplacement);XCTAssertEqual(system.songs.count,1)
XCTAssertNotEqual(system.a.song?.id,999);XCTAssertFalse(system.a.player.playing)
}
@MainActor func testArtworkSelectsTrackJPGAndLoadsFromOriginalFolder() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let image = NSImage(size:NSSize(width:8,height:8));image.lockFocus();NSColor.red.setFill();NSBezierPath(rect:NSRect(x:0,y:0,width:8,height:8)).fill();image.unlockFocus()
let bitmap = try XCTUnwrap(NSBitmapImageRep(data:try XCTUnwrap(image.tiffRepresentation)))
let jpg = try XCTUnwrap(bitmap.representation(using:.jpeg,properties:[:]))
try jpg.write(to:root.appendingPathComponent("Cover.jpg"));try jpg.write(to:root.appendingPathComponent("Song.JPG"))
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
var song = Song(id:1,title:"Art",audio:audio.path,cdg:nil,rating:0,plays:0);song.originalPath = root.appendingPathComponent("Song.mp3").path
XCTAssertEqual(DJTrackInfo.sidecar(song)?.lastPathComponent,"Song.JPG")
let artwork = await DJTrackInfo.artwork(song);XCTAssertNotNil(artwork)
}
func testMixtrackGoPresetUsesActualProtocol14BitTempoAndPadModes() {
var protocolState = MixtrackGoProtocol()
let preset = MixtrackGoProtocol.mappings(source:123)
XCTAssertTrue(preset.contains{$0.status == 0x91 && $0.number == 0 && $0.action == "B Play"})
XCTAssertTrue(preset.contains{$0.status == 0xBF && $0.number == 8 && $0.action == "Crossfader"})
XCTAssertEqual(protocolState.consume(.init(status:0xB0,data1:0x09,data2:64))?.count,0)
XCTAssertEqual(protocolState.consume(.init(status:0xB0,data1:0x29,data2:0))?.first?.value,0.5)
XCTAssertEqual(protocolState.consume(.init(status:0x94,data1:0x14,data2:127))?.first?.action,"A Hot cue 1")
_ = protocolState.consume(.init(status:0x95,data1:0,data2:127))
XCTAssertEqual(protocolState.consume(.init(status:0x95,data1:0x17,data2:127))?.first?.action,"B Loop 8")
XCTAssertEqual(protocolState.consume(.init(status:0x95,data1:0x1F,data2:127))?.first?.action,"B Loop off")
let leds = protocolState.feedback(aPlaying:true,bPlaying:false,sync:true,aLoaded:true,bLoaded:true,cue:nil)
XCTAssertEqual(Array(leds.prefix(9)),[0x90,0,127,0x90,1,1,0x90,2,127])
}
@MainActor func testEQAndFilterDoubleClickActuallyResetKnobs() async throws {
var values = [6.0,-6.0,0.75]
let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<3,id:\.self) { i in DJKnob(label:["HIGH","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 2 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:240,height:80))
host.frame = NSRect(x:0,y:0,width:240,height:80)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.contentView = host
let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded()
for index in 0..<3 {
for click in 1...2 {
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:40+index*80,y:46),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:click,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
}
}
try await Task.sleep(nanoseconds:100_000_000)
}
XCTAssertEqual(values,[0,0,0])
}
}
extension KJamsCloneTests {
@MainActor func testMixtrackGoInstalledPresetReceivesTempoPadsAndIgnoresButtonReleases() throws {
let suite = "KJMix.Mixtrack.Tests.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults,connect:false);midi.installMixtrackGo()
var events = [(String,Double)]()
midi.onAction = { name,value,_ in if name != "Controller feedback" { events.append((name,value)) } }
midi.receive([0x90,0,127,0x90,0,0,0x80,0,0])
midi.receive([0xB0,0x09,64,0xB0,0x29,0])
midi.receive([0x95,0,127,0x95,0,0,0x95,0x17,127])
XCTAssertEqual(events.map{$0.0},["A Play","A Tempo","B Loop 8"])
XCTAssertEqual(events[1].1,0.5,accuracy:0.0001)
XCTAssertTrue(DJMIDI(defaults:defaults,connect:false).mixtrackEnabled)
}
}
extension KJamsCloneTests {
func testDJWaveBeatGuidesUseSourceBPMAndLimitOverviewDensity() {
XCTAssertEqual(DJWaveMarkers.beats(start:0,span:2,bpm:120,width:800),[0,1,2,3,4])
XCTAssertEqual(DJWaveMarkers.beats(start:1.1,span:1,bpm:120,width:800),[3,4])
XCTAssertTrue(DJWaveMarkers.beats(start:0,span:8,bpm:nil,width:800).isEmpty)
XCTAssertTrue(DJWaveMarkers.beats(start:0,span:8,bpm:0,width:800).isEmpty)
XCTAssertLessThanOrEqual(DJWaveMarkers.beats(start:0,span:7200,bpm:180,width:500).count,101)
}
}
+16 -22
View File
@@ -5,12 +5,12 @@ The main window now opens with the original karaoke workspace above a resizable
## Using the decks
- Use the DJ browser’s Import Files / Folder button for DJ music. It has its own `DJ/Library.sqlite`, `DJ/Media` and playlists under the app support folder. It starts empty; existing karaoke songs are not migrated. The tested recursive importer is reused with audio-only import, without CDG graphics. MP3, WAV, M4A, MP4/M4V/MOV audio, AIFF/AIF, CAF, FLAC and AAC extensions are accepted; decoding depends on macOS format support. Imports support progress, cancellation and duplicate detection.
- Select a DJ library row and use Load A/B, use the row’s A/B buttons, or drag a DJ library song onto a deck. Loading is paused, with no brief autoplay. Loading a playing deck replaces that track.
- Select a DJ library row and use Load A/B, use the row’s A/B buttons, or drag a DJ library song onto a deck. Loading is paused, with no brief autoplay. Finder audio files can also be dropped onto either deck. Dropping onto a playing deck asks for confirmation before importing or replacing anything; Cancel preserves playback and Replace & Load explicitly overrides protection. Library loads also respect the active-deck protection preference.
- Cyan identifies A; amber identifies B. Each platter’s angle follows actual source playback time, including tempo changes, pauses and seeks. Drag around a platter to jog two source seconds per revolution. This is seek-based jogging, not vinyl scratching.
- Waveforms come from decoded audio and reuse existing waveform caches. Click/drag to seek. A white position line and green loop region show current state.
- High/mid/low EQ and the bipolar filter are beside each platter. Gain and level knobs have been removed from the on-screen decks. Drag a knob vertically; double-click to reset. Knobs expose adjustable accessibility actions and current values. EQ uses the existing three-band filters; filter is low-pass left, bypass centre, high-pass right.
- Play/pause, stop-to-start, set/return cue and four set/jump hot cues are per deck. Right-click a hot cue to clear it. Manual IN/OUT loops do not need BPM. Beat loops require confirmed BPM; choose 1–32 beats.
- Enter source BPM (40–240) or tap at least three beats. Loading resets BPM to an unconfirmed placeholder. Sync matches the other deck’s effective tempo; it does not analyse BPM or align a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch.
- Play/pause, stop-to-start, set/return cue and four set/jump hot cues are per deck. Right-click a hot cue to clear it. Use the cue-time editor to set an exact cue in seconds or capture the current playhead without moving playback. Manual IN/OUT loops do not need BPM. Beat loops require an estimated, entered or tapped BPM; choose 1–32 beats. Loop toggles off on a second press and continues correctly at the end of a track.
- Loading estimates BPM conservatively from up to the first 90 seconds of audio, searching 70–180 BPM; silence and steady tones remain unknown. Estimates show BPM~ and can be replaced by entering source BPM (40–240) or tapping at least three beats. Either Sync button links both decks to deck A’s effective BPM and both buttons show active. Press either again to unlink and restore both original tempos. Sync follows changes to A’s tempo; it does not align beat phase or detect a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch.
- The crossfader and DJ output headroom affect only DJ playback. Equal-power and linear crossfader curves are selectable. The karaoke master and saved singer preferences are independent. The wing meters show individual deck peaks, not a summed master meter. There is no master limiter; use gain/levels appropriately.
- Karaoke and DJ music can play simultaneously. The existing background-to-singer interlock is preserved, but it does not automatically duck the new DJ decks.
@@ -28,26 +28,20 @@ Headphone preview is real, independent playback on another selected output devic
## Controllers
The user’s “Numark Mix Go” name is ambiguous. Numark documents DJ2GO, DJ2GO2 and DJ2GO2 Touch; DJ2GO2 Touch is class-compliant USB MIDI with an integrated audio interface. No exact model or physical unit has been confirmed.
The confirmed model is **Numark Mixtrack Go**. Its preset was independently implemented from the protocol addresses in the supplied [Mixxx mapping](https://github.com/josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx). [Numark’s product page](https://www.numark.com/new/mixtrack-go/) documents USB class compliance and Bluetooth MIDI.
Primary references:
- https://www.numark.com/product/dj2go2-touch
- https://www.numark.com/images/product_downloads/DJ2GO2Touch-UserGuide-v1.0.pdf
- https://www.numark.com/product/dj2go2
- https://www.numark.com/product/dj2go
In DJ Settings → MIDI, select the controller input and choose **Apply Mixtrack Go Preset**. The profile persists per source ID. Supported controls include transport, cue, shared Sync, deck load, 14-bit tempo, relative jog, channel faders, crossfader, master/cue volume, headphone preview, library browsing and preview, low/filter mode, and four hot-cue/loop pads with shifted clear/off actions. A standard identity request and transport, sync, cue and mode feedback are sent when a matching MIDI output is found. Deck B pad mode follows the XML address 95/00; the upstream JavaScript table contains a conflicting address.
Select the controller’s CoreMIDI input in MIDI Mapping, choose an action, click Learn Next Control, then press/move its hardware control. Mappings persist by source unique ID, channel, note/CC number; the learning gesture does not trigger playback. Select the source again after reconnecting. Note-off and zero-velocity note-on are ignored. CC zero remains valid for continuous controls. Relative signed and offset encoder modes apply only to Jog. Buttons, gain/EQ/filter/level/tempo, crossfader/master, cues/loop/sync/headphones and loading the selected browser row can be mapped.
Custom MIDI Learn remains available for note buttons and 7-bit CC controls. Select an action, click Learn Next Control, then operate the control. Learning does not trigger playback, releases are ignored, and continuous CC zero remains valid. The selected MIDI input is remembered across launches and automatically reconnected when it becomes available; choosing Not connected clears that saved selection. Presets and learned mappings remain saved per source.
**Not implemented:** factory mappings, controller certification, LED/output feedback, motor feedback, 14-bit controls, pitch bend mapping, HID/vendor handshakes or touch scratching. The implementation receives standard MIDI 1.0 notes and 7-bit CCs, with running-status, packet splitting, realtime and SysEx handling. MIDI source presence is discovered through CoreMIDI; automated tests exercise message decoding/learning/persistence and a real virtual CoreMIDI source with multiple packets, not a connected Numark.
**Hardware limitations:** no physical Mixtrack Go has been tested. LED feedback, USB/Bluetooth delivery and audio sockets need on-device verification. Its same-device master 1–2/headphones 3–4 routing is not implemented; cue currently requires a separate output device. Stems, sampler, FX pad modes, touch scratching, HID, motor feedback and controller certification are not implemented. Unsupported pad modes report their limitation rather than triggering unrelated actions.
## Verification — 5 November 2026
- Xcode 27.2 Debug build compiled for arm64 and x86_64, without distribution signing.
- Full macOS XCTest suite: **113 tests, zero failures** on Apple silicon. Intel compilation was verified; Intel runtime playback was not exercised.
- Added checks for crossfader power/limits, tempo matching validation, paused load, mixer isolation, tapped BPM and beat loops, simultaneous real audio rendering, MIDI running status/realtime/SysEx/packet splitting, mapping persistence/button releases, real virtual CoreMIDI source delivery, and independent recursive DJ imports/playlists/reopening/deduplication.
- Existing karaoke rotation, singer navigation, recursive CDG/audio import, background handoff/fades and offline EQ processing tests passed. Fixed-coordinate rotation UI tests were updated for the background strip’s new position.
- Captured and visually inspected the actual NSHostingView macOS interface at 1320 × 1100 points, with separate karaoke and DJ fixture records and loaded background audio. This is a native app capture, not the HTML design preview.
- Physical Numark controls, controller LEDs and separate physical audio outputs have not been exercised. Same-device master/cue channel pairs are not implemented. BPM/beat grids are not automatically analysed, and loops/jogs use seek scheduling rather than sample-accurate scratching.
- Debug build compiled for arm64 and x86_64 without distribution signing; the macOS XCTest suite passed **128 tests with zero failures** on Apple silicon. Intel runtime playback was not exercised.
- Tests cover actual looping playback including end-of-file, editable cues, linked Sync and tempo restoration, audio-derived BPM, Finder replacement confirmation, JPG artwork, real knob double-click resets, native fader orientation/actions, preset addresses, 14-bit tempo, pad modes, MIDI releases/persistence and virtual CoreMIDI input delivery.
- Existing karaoke, recursive CDG/audio import, independent DJ library/playlists, rotation, background handoff, media-copy repair and audio processing checks passed.
- Native console and settings captures use actual NSHostingView windows. Physical controller controls, LEDs and separate physical audio outputs remain untested. Loops/jogs use seek scheduling rather than sample-accurate scratching.
## Import repair and settings modal
@@ -57,14 +51,14 @@ The three zero-byte files observed in the local DJ media directory were repaired
Studio settings open as a dedicated modal from the toolbar, sidebar or Command-comma. Song Settings also opens as a titled modal with a Done action. The deprecated AVAsset metadata read in FileTagWriter now uses asynchronous loading.
Verification after these changes: Debug build succeeds; **116 tests pass with zero failures**, including partial-copy repair, source permission preservation, failed-verification/cancellation cleanup and readable empty-audio errors. Native settings modal capture was inspected.
Verification includes partial-copy repair, source permission preservation, failed-verification/cancellation cleanup and readable empty-audio errors.
## Continuous console and separate preferences
The deck area is now one continuous console following the supplied reference: track headers and overview waveforms above; mirrored tempo/platter/EQ/filter wings; stacked, source-timed eight-second scrolling waveforms in the centre; and a shared bottom transport strip with a centred rectangular-cap crossfader. Native vertical tempo faders have visible scale marks, keyboard/accessibility support and a tested control-to-binding action. Play, Cue, loop size, headphones and hot cues remain available. No on-screen gain or channel-level knobs are shown. Cover tiles currently show a music-note placeholder; album artwork and key analysis are not added.
The deck area is now one continuous console following the supplied reference: track headers and overview waveforms above; mirrored tempo/platter/EQ/filter wings; stacked, source-timed eight-second scrolling waveforms in the centre; and a shared bottom transport strip with a centred rectangular-cap crossfader. Native vertical tempo faders have visible scale marks, keyboard/accessibility support and a tested control-to-binding action. Play, Cue, loop size, headphones and hot cues remain available. No on-screen gain or channel-level knobs are shown. Track headers show embedded artwork or a nearby JPG/JPEG/PNG, prioritising a matching track name or cover/folder/front/artwork image. Platters have a clear rotating radial marker. Musical key analysis is not implemented.
Settings now use an icon-category toolbar with separate Karaoke and DJ selectors. DJ Settings opens directly in DJ scope; Audio Routing and MIDI open the corresponding DJ categories. Karaoke device routing applies only to the singer deck. DJ General, Sound, Library, Appearance and Advanced preferences are persisted independently in DJ/DJSettings.json. Working options include paused/automatic load, active-deck protection, reset EQ/filter on load, ±8/16/50% tempo range and inversion, equal-power/linear crossfade curves, output headroom, waveform colours/window/grid, rotating marker, inactive-deck dimming, library font/row height and optional hot-cue autoplay. MIDI learning is cancelled when leaving its settings category.
Settings now use an icon-category toolbar with separate Karaoke and DJ selectors. DJ Settings opens directly in DJ scope; Audio Routing and MIDI open the corresponding DJ categories. Karaoke device routing applies only to the singer deck. DJ General, Sound, Library, Appearance and Advanced preferences are persisted independently in DJ/DJSettings.json. Working options include paused/automatic load, active-deck protection, reset EQ/filter on load, ±8/16/50% tempo range and inversion, equal-power/linear crossfade curves, output headroom, waveform colours/window/grid, rotating marker, inactive-deck dimming, library font/row height and optional hot-cue autoplay. MIDI learning is cancelled when leaving its settings category. Singer Web is available only in Karaoke settings, with its existing server controls preserved.
No nonfunctional switches were added for DVS, booth/microphone routing, Neural Mix, automix, recording, Spotify or cloud sync. Sync remains entered/tapped BPM tempo matching within the chosen range; centre waveform grids mark time, not automatically detected beats.
No nonfunctional switches were added for DVS, booth/microphone routing, Neural Mix, automix, recording, Spotify or cloud sync. Sync uses estimated or entered/tapped BPM for tempo matching; both overview and scrolling waveforms show BPM-relative beat guides, with stronger marks every four beats. Guides begin at source time zero and are not analysed downbeats. Active loops are highlighted green with IN/OUT boundary lines in both views.
Validation: full suite **119 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp.
Validation: full suite **128 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp.