diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index 192f248..fd9ae1d 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + B40000000000000000000001 /* DJAutomix.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40000000000000000000002; }; B30000000000000000000001 /* DJAudioRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002; }; B20000000000000000000001 /* DJXMLMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002; }; B20000000000000000000003 /* MIDIMappings in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004; }; @@ -37,6 +38,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = ""; }; B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = ""; }; B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = ""; }; B20000000000000000000004 /* MIDIMappings */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MIDIMappings; sourceTree = ""; }; @@ -76,6 +78,7 @@ B10000000000000000000002 /* DJWorkstation.swift */, B20000000000000000000002 /* DJXMLMapping.swift */, B30000000000000000000002 /* DJAudioRouting.swift */, + B40000000000000000000002 /* DJAutomix.swift */, B20000000000000000000004 /* MIDIMappings */, 000000000000000000000002 /* Library.swift */, 000000000000000000000003 /* CDG.swift */, @@ -200,6 +203,7 @@ B10000000000000000000001 /* DJWorkstation.swift in Sources */, B20000000000000000000001 /* DJXMLMapping.swift in Sources */, B30000000000000000000001 /* DJAudioRouting.swift in Sources */, + B40000000000000000000001 /* DJAutomix.swift in Sources */, 000000000000000000000066 /* Library.swift in Sources */, 000000000000000000000067 /* CDG.swift in Sources */, 000000000000000000000068 /* Importer.swift in Sources */, diff --git a/KJamsClone/DJAutomix.swift b/KJamsClone/DJAutomix.swift new file mode 100644 index 0000000..731afde --- /dev/null +++ b/KJamsClone/DJAutomix.swift @@ -0,0 +1,188 @@ +import SwiftUI +import UniformTypeIdentifiers + +@MainActor final class DJAutomix: ObservableObject { + struct Entry: Codable, Identifiable, Equatable { + var id = UUID() + var song: Int64 + var title: String + var artist: String + } + private struct Saved: Codable { var entries: [Entry];var seconds: Double;var matchTempo: Bool } + private struct Prepared { var entry: Entry;var deck: String } + private struct Fade { var from: String;var to: String;var start: Double;var duration: Double } + @Published private(set) var entries = [Entry]() + @Published private(set) var enabled = false + @Published private(set) var activeDeck: String? + @Published private(set) var progress: Double? + @Published private(set) var message = "Add tracks from the DJ library" + @Published var seconds: Double = 8 { didSet { save() } } + @Published var matchTempo = false { didSet { save() } } + private weak var system: DJSystem? + private let file: URL + private var prepared: Prepared? + private var fade: Fade? + private var task: Task? + private var expectedFader: Double = 0 + init(system: DJSystem) { + self.system = system;file = system.root.appendingPathComponent("DJQueue.json") + if let data = try? Data(contentsOf:file),let saved = try? JSONDecoder().decode(Saved.self,from:data) { + entries = Array(saved.entries.prefix(10000));seconds = saved.seconds.isFinite ? min(30,max(0,saved.seconds)) : 8;matchTempo = saved.matchTempo + } + } + deinit { task?.cancel() } + private func save() { + do { try JSONEncoder().encode(Saved(entries:entries,seconds:seconds,matchTempo:matchTempo)).write(to:file,options:.atomic) } + catch { message = "Queue could not be saved: \(error.localizedDescription)" } + } + func enqueue(_ songs: [Song]) { + entries += songs.prefix(max(0,10000-entries.count)).map { Entry(song:$0.id,title:$0.title,artist:$0.artist) };save() + } + func remove(_ id: UUID) { entries.removeAll{$0.id == id};save() } + func clear() { entries = [];save() } + func shuffle() { entries.shuffle();save() } + func move(_ id: UUID,by offset: Int) { + guard let index = entries.firstIndex(where:{$0.id == id}) else { return } + let target = min(entries.count-1,max(0,index+offset));guard target != index else { return } + let entry = entries.remove(at:index);entries.insert(entry,at:target);save() + } + func stop(_ reason: String = "Automix off · playback remains under manual control") { + enabled = false;task?.cancel();task = nil;fade = nil;prepared = nil;progress = nil;activeDeck = nil;message = reason + } + func manualTakeover() { if enabled { stop("Automix off · manual control") } } + func start(runTimer: Bool = true) { + guard let system,!enabled else { return } + guard system.routing.mode == "Internal" else { message = "Automix requires Internal mixer mode";return } + let playing = [system.a,system.b].filter{$0.player.playing} + guard playing.count <= 1 else { message = "Pause one deck before starting Automix";return } + system.disableSync() + if let deck = playing.first { activeDeck = deck.letter } + else { + guard !entries.isEmpty else { message = "Add tracks to the DJ queue first";return } + activeDeck = "A";setFader(0) + guard let next = prepare(on:"A") else { activeDeck = nil;return } + entries.removeAll{$0.id == next.entry.id};save();system.a.player.toggle() + } + guard let activeDeck else { return } + setFader(activeDeck == "A" ? 0 : 1);enabled = true;message = "Automix on · deck \(activeDeck)" + _ = prepare(on:activeDeck == "A" ? "B" : "A") + if runTimer { + task = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(nanoseconds:50_000_000) } catch { return } + self?.tick() + } + } + } + } + private func setFader(_ value: Double) { expectedFader = value;system?.crossfader = value } + private func prepare(on letter: String) -> Prepared? { + guard let system,!system.channel(letter).player.playing else { return nil } + while let entry = entries.first { + guard let song = system.songs.first(where:{$0.id == entry.song}) else { entries.removeFirst();save();message = "Skipped unavailable track: \(entry.title)";continue } + system.load(song,into:letter,automated:true) + guard system.channel(letter).player.currentSongID == song.id else { + entries.removeFirst();save();message = "Skipped unreadable track: \(entry.title)";continue + } + let next = Prepared(entry:entry,deck:letter);prepared = next;return next + } + prepared = nil;return nil + } + func mixNow(now: Double = ProcessInfo.processInfo.systemUptime) { + guard enabled,fade == nil,let system,let activeDeck else { return } + if prepared?.entry.id != entries.first?.id { prepared = nil } + if prepared == nil { _ = prepare(on:activeDeck == "A" ? "B" : "A") } + guard let next = prepared else { message = "No next track queued";return } + let outgoing = system.channel(activeDeck),incoming = system.channel(next.deck) + guard incoming.player.currentSongID == next.entry.song,!incoming.player.playing else { stop("Automix off · deck changed manually");return } + if matchTempo,outgoing.bpmConfirmed,incoming.bpmConfirmed { + let rate = outgoing.bpm*outgoing.player.tempo/incoming.bpm,range = system.settings.tempoRange/100 + if rate.isFinite,(1-range...1+range).contains(rate) { incoming.player.tempo = rate } + } + let remaining = max(0,(outgoing.player.duration-outgoing.player.time)/max(0.5,outgoing.player.tempo)) + let duration = min(max(0,seconds),remaining,incoming.player.duration/max(0.5,incoming.player.tempo)/2) + entries.removeAll{$0.id == next.entry.id};save();prepared = nil + incoming.player.toggle() + fade = Fade(from:activeDeck,to:next.deck,start:now,duration:duration);progress = 0 + message = "Mixing \(activeDeck) → \(next.deck)" + if duration == 0 { finishFade() } + } + func tick(now: Double = ProcessInfo.processInfo.systemUptime) { + guard enabled,let system,let activeDeck else { return } + guard system.routing.mode == "Internal" else { stop("Automix off · external routing selected");return } + guard abs(system.crossfader-expectedFader) < 0.03 else { stop("Automix off · crossfader moved manually");return } + if let fade { + guard system.channel(fade.to).player.playing else { stop("Automix off · incoming deck paused");return } + let amount = min(1,max(0,(now-fade.start)/max(0.001,fade.duration))) + progress = amount;setFader(fade.from == "A" ? amount : 1-amount) + if amount >= 1 { finishFade() };return + } + let player = system.channel(activeDeck).player + if player.loopEnabled { message = "Loop active · exit the loop to continue Automix";return } + if !player.playing { + if player.time >= player.duration-0.05 { + if entries.isEmpty { stop("Queue finished");return } + mixNow(now:now) + } else { stop("Automix off · active deck paused") } + return + } + if prepared?.entry.id != entries.first?.id { prepared = nil } + let idle = activeDeck == "A" ? "B" : "A" + if prepared == nil { _ = prepare(on:idle) } + if let prepared,system.channel(prepared.deck).player.playing { stop("Automix off · second deck started manually");return } + let remaining = max(0,(player.duration-player.time)/max(0.5,player.tempo)) + message = entries.isEmpty ? "Last track · queue finishes at the end" : "Next transition in \(Int(max(0,remaining-max(0,seconds)))) s" + if !entries.isEmpty,remaining <= max(0.05,seconds) { mixNow(now:now) } + } + private func finishFade() { + guard let fade,let system else { return } + setFader(fade.to == "A" ? 0 : 1);system.channel(fade.from).player.stopDeck() + activeDeck = fade.to;self.fade = nil;progress = nil + message = "Automix on · deck \(fade.to)" + _ = prepare(on:fade.from) + } +} + +struct DJQueuePane: View { + @ObservedObject var system: DJSystem + @ObservedObject var automix: DJAutomix + var body: some View { + VStack(alignment:.leading,spacing:8) { + HStack { Label("DJ Queue",systemImage:"text.line.first.and.arrowtriangle.forward").font(.headline);Spacer();Text("\(automix.entries.count)").foregroundStyle(.secondary) } + HStack { + Button(automix.enabled ? "Stop Automix" : "Start Automix") { if automix.enabled { automix.stop() } else { automix.start() } }.tint(automix.enabled ? .green : .accentColor) + Button("Mix Now") { automix.mixNow() }.disabled(!automix.enabled || automix.entries.isEmpty || automix.progress != nil) + }.controlSize(.small) + HStack { + Text("Fade");Slider(value:$automix.seconds,in:0...30,step:1);Text("\(Int(automix.seconds)) s").monospacedDigit().frame(width:34) + }.font(.caption) + Toggle("Match BPM when available",isOn:$automix.matchTempo).font(.caption) + if let progress = automix.progress { ProgressView(value:progress) } + List { + ForEach(automix.entries) { entry in + HStack(spacing:5) { + VStack(alignment:.leading,spacing:2) { Text(entry.title).lineLimit(1);Text(entry.artist).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } + Spacer(minLength:0) + Button { automix.move(entry.id,by:-1) } label: { Image(systemName:"chevron.up") }.accessibilityLabel("Move earlier") + Button { automix.move(entry.id,by:1) } label: { Image(systemName:"chevron.down") }.accessibilityLabel("Move later") + Button { automix.remove(entry.id) } label: { Image(systemName:"xmark") }.accessibilityLabel("Remove from queue") + }.font(.caption).buttonStyle(.borderless) + } + if automix.entries.isEmpty { Text("Add songs with + or drag them here from the DJ library.").font(.caption).foregroundStyle(.secondary) } + }.listStyle(.inset).scrollContentBackground(.hidden) + .onDrop(of:["public.text"],isTargeted:nil) { providers in + guard let provider = providers.first else { return false } + Task { @MainActor in + do { + let reference = try await SongDragReference.load(provider) + guard reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return } + automix.enqueue([song]) + } catch { system.error = error.localizedDescription } + } + return true + } + HStack { Button("Shuffle") { automix.shuffle() };Button("Clear") { automix.clear() };Spacer() }.controlSize(.mini).disabled(automix.entries.isEmpty) + Text(automix.message).font(.caption2).foregroundStyle(.secondary).lineLimit(2) + }.padding(12).background(Color.white.opacity(0.035),in:RoundedRectangle(cornerRadius:6)) + } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 0b47e69..e568680 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -60,7 +60,13 @@ struct DJPreferences: Codable, Equatable { private var analysisTask: Task? @Published var gain: Double = 0 { didSet { player.inputGain = gain } } @Published var level: Double = 0.8 { didSet { updateLevel() } } - @Published var loopBeats = 8 + @Published var loopBeats = 8 { + didSet { + guard oldValue != loopBeats,loopBeats > 0,player.loopEnabled,let start = player.loopIn,let end = player.loopOut else { return } + let length = bpmConfirmed ? Double(loopBeats)*60/bpm : (end-start)*Double(loopBeats)/Double(max(1,oldValue)) + player.defineLoop(start:start,end:min(player.duration,start+length)) + } + } var master: Double = 0.8 { didSet { updateLevel() } } var crossWeight: Double = 1 { didSet { updateLevel() } } private var taps = [Double]() @@ -123,16 +129,15 @@ struct DJAudioDevice: Identifiable, Equatable { let result = ids.withUnsafeMutableBytes { AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,$0.baseAddress!) } guard result == noErr else { return [] } return ids.compactMap { id in - var streams = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreams,mScope:kAudioDevicePropertyScopeOutput,mElement:kAudioObjectPropertyElementMain) - var count: UInt32 = 0 - guard AudioObjectGetPropertyDataSize(id,&streams,0,nil,&count) == noErr,count > 0 else { return nil } + let channels = channelCount(id) + guard channels > 0 else { return nil } func string(_ selector: AudioObjectPropertySelector) -> String? { var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var text: Unmanaged?;var bytes = UInt32(MemoryLayout.size) guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil } return text?.takeRetainedValue() as String? } - return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channelCount(id)) + return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channels) } } static func defaultOutput() -> AudioDeviceID? { @@ -146,6 +151,7 @@ struct DJAudioDevice: Identifiable, Equatable { let a = DJChannel("A"),b = DJChannel("B") let preview = Player(persistsMix:false) let midi = DJMIDI() + lazy var automix = DJAutomix(system:self) @Published var crossfader: Double = 0.5 { didSet { applyMix() } } @Published var master: Double = 0.8 { didSet { applyMix() } } @Published private(set) var syncActive = false @@ -234,10 +240,11 @@ struct DJAudioDevice: Identifiable, Equatable { func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } } func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 } func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b } - func load(_ song: Song,into letter: String,overridePlaying: Bool = false) { + func load(_ song: Song,into letter: String,overridePlaying: Bool = false,automated: Bool = false) { perform { let deck = channel(letter) if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return } + if !automated { automix.manualTakeover() } if syncActive { disableSync() } if previewDeck == letter { stopPreview() } try deck.load(song) @@ -246,7 +253,7 @@ struct DJAudioDevice: Identifiable, Equatable { do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error } } if settings.resetEQOnLoad { deck.player.resetTone() } - if settings.autoPlayOnLoad { deck.player.toggle() } + if settings.autoPlayOnLoad && !automated { deck.player.toggle() } status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops" } } @@ -263,7 +270,7 @@ struct DJAudioDevice: Identifiable, Equatable { let previous = routing perform { try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) - stopPreview() + automix.manualTakeover();stopPreview() do { for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic) @@ -341,11 +348,12 @@ struct DJAudioDevice: Identifiable, Equatable { } func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) { if name == "Controller feedback" { updateControllerFeedback();return } - if name == "Crossfader" { crossfader = min(1,max(0,value));return } + if name == "Crossfader" { automix.manualTakeover();crossfader = min(1,max(0,value));return } if name == "Master" { master = min(1,max(0,value));return } if name == "Cue volume" { cueLevel = min(1,max(0,value));return } let parts = name.split(separator:" ",maxSplits:1).map(String.init) guard parts.count == 2 else { return };let d = channel(parts[0]),p = d.player + if ["Play","Stop","Cue","Cue button","Cue play","Sync","Load","Tempo","Tempo XML","Jog","Jog ticks"].contains(parts[1]) { automix.manualTakeover() } switch parts[1] { case "Play": p.toggle() case "Stop": p.stopDeck() @@ -359,7 +367,6 @@ struct DJAudioDevice: Identifiable, Equatable { case "Loop double","Loop half": let factor = parts[1] == "Loop double" ? 2.0 : 0.5 d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor))) - if p.loopEnabled,let start = p.loopIn,let end = p.loopOut { p.defineLoop(start:start,end:min(p.duration,start+(end-start)*factor)) } case "Tempo XML": let rate = 1+(value*2-1)*settings.tempoRange/100 if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate } @@ -430,6 +437,8 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { var activeXML: DJXMLProfile? { profiles[String(sourceID)]?.hasPrefix("XML:") == true ? xmlAssignments[String(sourceID)] : nil } private var mixtrack = MixtrackGoProtocol() private var outputPort = MIDIPortRef(0),outputDestination = MIDIEndpointRef(0) + private var closing = false + private var terminationObserver: AnyCancellable? private var initializationTask: Task? private var lastFeedback = [UInt8]() @Published var learning: String? @@ -448,6 +457,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:] if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved } guard connect else { return } + terminationObserver = NotificationCenter.default.publisher(for:NSApplication.willTerminateNotification).sink { [weak self] _ in self?.shutdown() } let result = MIDIClientCreateWithBlock("KJMix DJ" as CFString,&client) { [weak self] _ in Task { @MainActor in self?.refreshSources() } } guard result == noErr else { status = "MIDI unavailable (\(result))";return } MIDIOutputPortCreate(client,"DJ feedback" as CFString,&outputPort) @@ -489,6 +499,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = [] } func select(_ id: Int32) { + guard !closing else { return } disconnect() if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");autoDetect = false;return } guard let source = sources.first(where:{$0.id == id}) else { return } @@ -498,10 +509,12 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if let profile = DJXMLProfile.match(source.name,in:xmlCatalog) { applyXML(profile) } else if DJXMLProfile.normalized(source.name) == "numark mixtrack go" { installMixtrackGo() } } else if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) } + else if profiles[String(id)] == "Mixtrack Go" { configureMixtrackMappings();onAction?("Controller feedback",0,false) } } else { status = "Could not connect MIDI source (\(result))" } } func receive(_ bytes: [UInt8]) { + guard !closing else { return } for message in decoder.feed(bytes) { let type = message.status & 0xF0 if let profile = activeXML,profile.adapter == nil,learning == nil { @@ -539,14 +552,24 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { } } var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" } - func installMixtrackGo() { + private func configureMixtrackMappings() { let preset = MixtrackGoProtocol.mappings(source:sourceID) - let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset - profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");mixtrack = .init();save() - initializeMixtrack() - status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pad modes. Hardware testing required." + let ids = Set(preset.map(\.id)) + mappings.removeAll { ids.contains($0.id) };mappings += preset;mixtrack = .init();save();initializeMixtrack() + } + func installMixtrackGo() { + profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1") + configureMixtrackMappings() + status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pads and LED feedback" onAction?("Controller feedback",0,false) } + func shutdown() { resetController();closing = true } + func resetController() { + initializationTask?.cancel();initializationTask = nil;lastFeedback = [];learning = nil + if mixtrackEnabled { sendBytes(MixtrackGoProtocol.shutdownBytes) } + else if let outputs = activeXML?.outputs { for output in outputs { sendBytes([output.status,output.number,0]) } } + mixtrack = .init() + } func useCustomProfile() { initializationTask?.cancel();lastFeedback = [];profiles[String(sourceID)] = "Custom";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");status = "Custom MIDI learn active" } func mergeXML(_ imported: [DJXMLProfile]) { let ids = Set(imported.map(\.id));xmlCatalog.removeAll { ids.contains($0.id) };xmlCatalog += imported @@ -565,9 +588,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") } pickupValues = [:];pickedUp = [];learning = nil if profile.adapter == "Mixtrack Go" { - mixtrack = .init();let preset = MixtrackGoProtocol.mappings(source:sourceID) - let ids = Set(preset.map(\.id));mappings.removeAll { ids.contains($0.id) };mappings += preset;save() - initializeMixtrack() + configureMixtrackMappings() } onAction?("Controller feedback",0,false) status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report" @@ -594,6 +615,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable { } } func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) { + guard !closing else { return } if !mixtrackEnabled { guard let outputs = activeXML?.outputs else { return } for output in outputs { @@ -671,7 +693,10 @@ struct DJConsole: View { VStack(spacing:10) { header DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}) - browser.frame(height:expanded ? 300 : 130) + HStack(alignment:.top,spacing:12) { + browser.frame(maxWidth:.infinity).frame(height:expanded ? 330 : 220) + DJQueuePane(system:system,automix:system.automix).frame(width:310,height:expanded ? 330 : 220) + } if let progress = system.importProgress { HStack { Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit() @@ -725,6 +750,7 @@ struct DJConsole: View { } } label: { Image(systemName:"folder.badge.plus") }.frame(width:25) TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder) + Button { system.automix.enqueue(songs) } label: { Image(systemName:"text.badge.plus") }.help("Add this library or playlist to the DJ queue").disabled(songs.isEmpty) Button("Import Files / Folder…") { importMedia() } Spacer(minLength:0) Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0) @@ -738,6 +764,7 @@ struct DJConsole: View { Text(song.title).font(.system(size:system.settings.libraryFont,weight:.medium)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading) Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading) Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35) + Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue") Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A") Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B") }.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini) @@ -749,6 +776,7 @@ struct DJConsole: View { } } @ViewBuilder private func playlistMenu(_ song: Song) -> some View { + Button("Add to DJ Queue") { system.automix.enqueue([song]) } Menu("Add to Playlist") { ForEach(system.playlists) { list in Button(list.name) { system.addToPlaylist(list.id,song:song.id);refreshPlaylist() } } } if let playlist { Button("Move Earlier") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:-1) };refreshPlaylist() } @@ -931,7 +959,7 @@ private struct DJMappingSheet: View { VStack(alignment:.leading,spacing:14) { HStack { Text("MIDI Controller Mapping").font(.title2.bold());Spacer();Button("Refresh") { midi.refreshSources() } } Picker("MIDI input",selection:Binding(get:{midi.sourceID},set:{midi.select($0)})) { Text("Not connected").tag(Int32(0));ForEach(midi.sources) { Text($0.name).tag($0.id) } } - HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green);Button("Use Custom Learn") { midi.useCustomProfile() } } } + HStack { Button("Apply Mixtrack Go Preset") { midi.installMixtrackGo() }.disabled(midi.sourceID == 0);if midi.mixtrackEnabled { Text("Mixtrack Go active").foregroundStyle(.green) } } Toggle("Automatically detect and reconnect supported controllers",isOn:$midi.autoDetect) VStack(alignment:.leading,spacing:8) { HStack { Text("XML Controller Profiles").font(.headline);Spacer();Button("Import XML…") { midi.importXMLFiles() } } @@ -1442,6 +1470,15 @@ private struct DJCueEditor: View { /// Protocol facts adapted from josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx. /// XML is authoritative for the right Mode button (95 00); the JS table has a conflicting entry. struct MixtrackGoProtocol { + static var shutdownBytes: [UInt8] { + var bytes: [UInt8] = [0x9E,0x7F,0] + for i in 0..<2 { + for note in [0,1,2,3,4,5,0x46,0x48,0x54] { bytes += [UInt8(0x90+i),UInt8(note),0] } + bytes += [UInt8(0x80+i),0x1B,0,0x9F,UInt8(2+i),0,0x9F,UInt8(0x48+i),0] + for note in Array(1...4)+Array(0x14...0x17)+Array(0x1C...0x1F) { bytes += [UInt8(0x94+i),UInt8(note),0] } + } + return bytes+[0x9F,0x46,0,0x9F,0x47,0] + } struct Event { var action: String;var value: Double = 1;var relative = false } var modes = [0,0] var lowEQ = false diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 8f4b349..a062851 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2150,6 +2150,12 @@ extension KJamsCloneTests { let initial = received.count try await Task.sleep(nanoseconds:2_100_000_000) XCTAssertGreaterThan(received.count,initial,"LED state is resent after the startup light show ends") + midi.applyXML(profile);midi.shutdown() + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertTrue((0.. (DJSystem,[Song]) { + let system = DJSystem(storageURL:root),library = try XCTUnwrap(system.library) + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + for name in ["First","Second","Third"] { + let copy = root.appendingPathComponent(name+".mp3");try FileManager.default.copyItem(at:fixture,to:copy) + try library.add(title:name,audio:copy.path,cdg:nil) + } + try system.refresh();return (system,system.songs) + } + @MainActor func testChangingLoopSizeResizesActiveAudioRegionImmediately() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let (system,songs) = try automixFixture(root);system.load(songs[0],into:"A") + let deck = system.a,player = deck.player;defer { player.stop() } + deck.bpm = 240;deck.bpmConfirmed = true;deck.loopBeats = 4 + player.defineLoop(start:0.25,end:1.25);player.seek(0.5);player.toggle() + deck.loopBeats = 8 + XCTAssertEqual(player.loopOut!,2.25,accuracy:0.001);XCTAssertTrue(player.playing);XCTAssertTrue(player.loopEnabled) + deck.loopBeats = 2;XCTAssertEqual(player.loopOut!,0.75,accuracy:0.001) + XCTAssertEqual(player.loopIn!,0.25,accuracy:0.001);XCTAssertTrue(player.playing) + deck.loopBeats = 32;XCTAssertEqual(player.loopOut!,player.duration,accuracy:0.001) + } + @MainActor func testDJQueueOrderDuplicatesAndSettingsPersistWithoutAutoplay() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let (system,songs) = try automixFixture(root),queue = system.automix + queue.enqueue([songs[0],songs[1],songs[0]]);queue.seconds = 5;queue.matchTempo = true + let second = queue.entries[1].id;queue.move(second,by:-1) + XCTAssertEqual(queue.entries.map(\.song),[songs[1].id,songs[0].id,songs[0].id]) + queue.remove(queue.entries.last!.id) + let restored = DJSystem(storageURL:root),saved = restored.automix + XCTAssertEqual(saved.entries.map(\.song),[songs[1].id,songs[0].id]);XCTAssertEqual(saved.seconds,5);XCTAssertTrue(saved.matchTempo) + XCTAssertFalse(saved.enabled);XCTAssertFalse(restored.a.player.playing);XCTAssertFalse(restored.b.player.playing) + } + @MainActor func testAutomixCrossfadesBothDecksAndYieldsToManualControl() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let (system,songs) = try automixFixture(root),queue = system.automix + defer { queue.stop();system.a.player.stop();system.b.player.stop() } + system.settings.autoPlayOnLoad = true;queue.enqueue(songs);queue.seconds = 0.2;queue.start(runTimer:false) + XCTAssertTrue(queue.enabled);XCTAssertTrue(system.a.player.playing);XCTAssertFalse(system.b.player.playing,"Preloaded track stays paused despite autoplay preference") + queue.mixNow(now:10);XCTAssertTrue(system.b.player.playing) + queue.tick(now:10.1);XCTAssertEqual(system.crossfader,0.5,accuracy:0.01) + queue.tick(now:10.3);XCTAssertEqual(queue.activeDeck,"B");XCTAssertEqual(system.crossfader,1) + XCTAssertFalse(system.a.player.playing);XCTAssertTrue(system.b.player.playing);XCTAssertEqual(queue.entries.count,1) + XCTAssertEqual(system.a.player.currentSongID,songs[2].id,"Next track is preloaded into the now silent deck") + system.crossfader = 0.5;queue.tick(now:11);XCTAssertFalse(queue.enabled);XCTAssertTrue(system.b.player.playing) + } + @MainActor func testAutomixTimerTransitionsAtTrackEndAndHonoursPause() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let (system,songs) = try automixFixture(root),queue = system.automix + defer { queue.stop();system.a.player.stop();system.b.player.stop() } + queue.seconds = 0.1;queue.enqueue(Array(songs.prefix(2)));queue.start() + system.a.player.seek(system.a.player.duration-0.15) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertEqual(queue.activeDeck,"B");XCTAssertTrue(system.b.player.playing);XCTAssertFalse(system.a.player.playing) + system.b.player.toggle();try await Task.sleep(nanoseconds:120_000_000) + XCTAssertFalse(queue.enabled,"Manual pause hands control back to the operator") + } + @MainActor func testAutomixWaitsForLoopsAndSkipsUnavailableQueueEntries() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let (system,songs) = try automixFixture(root),queue = system.automix + defer { queue.stop();system.a.player.stop();system.b.player.stop() } + let missing = try XCTUnwrap(system.library).add(title:"Missing",audio:root.appendingPathComponent("missing.mp3").path,cdg:nil) + try system.refresh();queue.enqueue([try XCTUnwrap(system.songs.first{$0.id == missing}),songs[0],songs[1]]) + queue.start(runTimer:false);XCTAssertEqual(system.a.player.currentSongID,songs[0].id) + system.a.player.defineLoop(start:0,end:0.5);queue.tick(now:10) + XCTAssertNil(queue.progress);XCTAssertTrue(queue.enabled);XCTAssertTrue(queue.message.contains("Loop active")) + queue.stop();XCTAssertTrue(system.a.player.playing,"Stopping automation preserves current playback") + } +} diff --git a/README.md b/README.md index 0700251..256e50e 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,6 @@ See [the Reinterpret Names guide](docs/REINTERPRETING.md) for folder templates, Key and Tempo controls beside the main transport adjust the loaded performance immediately. Changes save to the performing singer and song, even while browsing another singer or track. With no assigned performer, live changes are temporary. The library shows 20 songs per page; search and sorting cover the entire library, and Next Song can cross page boundaries. -### Optional DJ console development +## DJ Studio -The main window provides Karaoke and DJ Studio tabs; both retain playback while switching. DJ Studio has its own library, MIDI profiles and saved multichannel audio routing. See [DJ workstation notes](docs/DJ_WORKSTATION.md) for supported controls, XML profile compatibility and hardware verification. +The main window provides Karaoke and DJ Studio tabs; both retain playback while switching. DJ Studio has its own library, saved queue, Automix, MIDI profiles and saved multichannel audio routing. See [DJ workstation notes](docs/DJ_WORKSTATION.md) for supported controls, XML profile compatibility and hardware verification. diff --git a/docs/DJ_FEATURE_ROADMAP.md b/docs/DJ_FEATURE_ROADMAP.md new file mode 100644 index 0000000..1d4b616 --- /dev/null +++ b/docs/DJ_FEATURE_ROADMAP.md @@ -0,0 +1,27 @@ +# DJ Studio feature stages + +This is a staged expansion of KJMix Pro using djay Pro as a workflow reference. It does not claim complete compatibility with djay’s proprietary engines or controller processors. + +## Stage 1 — DJ queue and Automix + +Implemented: independent saved queue, library/playlist enqueue, duplicate entries, reorder/remove/shuffle, paused preloading, timed crossfade or cut, optional incoming BPM match, Mix Now, manual takeover, missing-file handling and no autoplay after relaunch. Tests exercise real playback and crossfader transitions. + +Current fixes also include active-loop resizing during playback, controller shutdown reset, automatic MIDI detection, XML profiles, two persistent workspaces and multichannel Internal/External audio routing. See [workstation notes](DJ_WORKSTATION.md) for functionality and verification limits. + +## Stage 2 — Effects and sampler + +Planned: per-deck audio effects with real signal routing, dry/wet and parameter controls; sample-bank import, trigger/stop/volume; controller bindings and meaningful LED feedback. No placeholder switches should imply audio processing exists. + +## Stage 3 — Recording and expanded decks + +Planned: mix recording, file management and reliable start/stop finalization; four-deck playback, mixer and routing; expanded cue/loop storage and library metadata. + +## Later stages + +- Analysis: musical key, editable beat/downbeat grids, per-track saved cues/loops and improved BPM reliability. +- Mixing: configurable transition points, repeat/shuffle policies, gain analysis, limiter and improved preview tracking. +- Hardware: broader conditional/modifier mapping support, device-specific processors, controller feedback and testing on additional interfaces. +- Input/video: microphone/booth routing, recording input and video workflows after the underlying audio routes are verified. +- Service-dependent features: streaming integrations require their supported SDKs and service access; stems/Neural Mix, DVS and proprietary hardware functionality require separate implementation and verification. + +The next stages remain planned work. Stage 1 automates timed transitions; it does not reproduce AI phrase selection or djay’s proprietary feature set. diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index e5a45f6..671281b 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -9,7 +9,7 @@ The main window has two tabs: **Karaoke** and **DJ Studio**. Both workspaces sta - 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. 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. +- 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. Changing the loop size resizes an active loop immediately, preserving its start and continuing playback; the end is clamped to track duration. - 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. @@ -18,6 +18,14 @@ The main window has two tabs: **Karaoke** and **DJ Studio**. Both workspaces sta The DJ browser supports title/artist/album search and paginates in groups of 50. DJ playlists are stored independently from karaoke. Create, rename or delete them from the folder menu; right-click a song to add it to a playlist, remove it, or move it earlier/later. Karaoke library drag references are rejected by the DJ decks; import the source music using the DJ importer. DRM subscription streaming is not added. +## DJ queue and Automix — first feature stage + +The DJ queue is independent of karaoke and DJ playlists. Add a song with its + button, use Add to DJ Queue in its context menu, add the current filtered library/playlist with the queue button, or drag from the DJ library into the queue. Duplicate songs are allowed. Reorder with the arrow buttons, remove entries, shuffle or clear. Queue order, fade duration and BPM-match preference save in `DJ/DJQueue.json`; relaunch never starts playback automatically. + +Start Automix uses an already playing deck, or loads the first queued song into A. It preloads the next song paused on the silent deck, regardless of the load-autoplay preference. Near the current track’s end, it starts the next deck and moves the real crossfader over the selected 0–30 seconds. Zero seconds is a cut; short tracks shorten the fade. Mix Now begins the transition immediately. BPM matching optionally adjusts the incoming tempo when both BPM values are available and the ratio fits the selected tempo range. This is timed crossfading, not djay’s AI phrase selection or beat-phase alignment; see [djay’s Automix workflow](https://help.algoriddim.com/user-manual/djay-pro-mac/mixing-basics/using-automix) for the reference feature set. + +Automix requires Internal mixer mode and starts only when at most one deck is playing. It waits for active loops, skips missing/unreadable queue entries, stops at the end of the final track, and yields to manual loading, transport, tempo/jog or crossfader control. Stop Automix relinquishes automatic control while playback remains available to the operator. Both DJ decks are used by automation; karaoke remains independent. + ## Audio routing DJ Settings → Devices lists CoreAudio outputs including the Mac’s speakers and the controller’s internal sound card, with automatic device-list refresh on connection changes. Choose a stereo channel pair next to each device, then Apply; Revert restores the applied configuration. Routing persists in `DJ/DJAudioRouting.json`. @@ -40,33 +48,24 @@ Automatic detection is enabled by default. It matches exact endpoint names and a The generic adapter handles standard note/CC and 14-bit pitch-bend bindings for available two-deck transport, cue/hot cues 1–4, loops, EQ/filter/gain, channel volume, crossfader, master/cue level, load and browse actions. It respects inversion, signed/offset relative encoders and soft takeover for supported absolute controls. Common play/cue/sync/headphone/loop/hot-cue LED outputs support explicit addresses, min/max values and inversion. Conflicting, conditional, modifier, HID or unsupported assignments are disabled and reported. djay custom processors, arbitrary predicates, displays, four-deck operation, proprietary scripts and rotary acceleration curves are not reproduced. -**Mixtrack Go** uses a dedicated native adapter when its supplied XML profile is selected or detected. This enables its 14-bit split-CC tempo, relative jog, channel faders, filter/low switch, browsing/loading, transport/shared Sync, headphone controls, hot-cue/loop pad modes and corresponding LED feedback. Apply Mixtrack Go Preset remains available as a direct alternative. Initialization sends identity and controller-state requests and refreshes LEDs after two seconds, because the startup demo can clear early LED state. Deck B pad mode uses XML address 95/00. Supported transport, mode, load, shifted transport and pad LEDs are driven from app state. +**Mixtrack Go** uses a dedicated native adapter when its supplied XML profile is selected or detected. This enables its 14-bit split-CC tempo, relative jog, channel faders, filter/low switch, browsing/loading, transport/shared Sync, headphone controls, hot-cue/loop pad modes and corresponding LED feedback. Apply Mixtrack Go Preset remains available as a direct alternative. Initialization sends identity and controller-state requests and refreshes LEDs after two seconds, because the startup demo can clear early LED state. Deck B pad mode uses XML address 95/00. Supported transport, mode, load, shifted transport and pad LEDs are driven from app state. App shutdown cancels initialization, clears the LEDs and pad-mode state, and ignores later feedback; selecting a saved direct preset repeats its startup handshake on reconnect. **Limitations:** stems, sampler, FX modes, touch scratching, HID, motor feedback and controller certification are not implemented. Unsupported pad modes report the limitation. Tests exercised actual CoreMIDI output to a receiving virtual destination, including delayed LED refresh. The attached Numark MIDI endpoints and audio device were discovered, and its channel maps were checked; physical knob/button behaviour, visible LED appearance, Bluetooth delivery and audible socket isolation still need hands-on verification. -## Verification — 5 November 2026 - -- Debug build compiled for arm64 and x86_64 without distribution signing; the macOS XCTest suite passed **137 tests with zero failures** on Apple silicon. Intel runtime playback was not exercised. -- Tests cover actual looping playback including end-of-file, editable cues, linked Sync and tempo restoration, audio-derived BPM, Finder replacement confirmation, JPG artwork, real knob double-click resets, native fader orientation/actions, preset addresses, 14-bit tempo, pad modes, MIDI releases/persistence and virtual CoreMIDI input delivery. -- Existing karaoke, recursive CDG/audio import, independent DJ library/playlists, rotation, background handoff, media-copy repair and audio processing checks passed. -- Native console and settings captures use actual NSHostingView windows. Virtual CoreMIDI output and attached-interface channel maps were tested; physical button/LED appearance and audible output isolation remain to be verified. Loops/jogs use seek scheduling rather than sample-accurate scratching. - -## Import repair and settings modal - -Media imports now stream audio/CDG content into a private staging file, verify its SHA-256, and atomically rename it into place. Source permissions, ACLs and protected security-scope metadata are not copied. Failed or cancelled transfers remove staging files and never publish a partial destination. Existing empty destinations are not accepted as completed imports; retrying repairs them. Empty playback files show a reimport instruction instead of the raw AVAudioFile error. - -The three zero-byte files observed in the local DJ media directory were repaired from checksum-matched originals. Their sources and library records were preserved; macOS afinfo successfully recognised all three repaired MP3s. The interrupted, unregistered item will be registered when its folder is imported again. - -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 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. 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. 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 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. +Waveform beat guides use BPM and source time zero, with stronger marks every four beats; they are not analysed downbeats. Active loops have green regions and IN/OUT boundary markers. DVS, Neural Mix, booth/microphone input, recording, Spotify and cloud sync are not implemented. -Validation: full suite **137 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp. +## Import integrity + +Media imports stream into a staging file, verify SHA-256, and atomically rename the result. Source permissions, ACLs and protected security-scope metadata are not copied. Failed or cancelled transfers remove staging files. Empty existing destinations are repaired on retry, and empty playback files show a reimport instruction. Original media and library records are preserved. + +## Verification — 5 November 2026 + +The full macOS suite covers karaoke, recursive CDG/audio import, independent DJ imports/playlists, rotation, background handoff, media repair, real audio loops, live loop resizing, linked Sync, cue editing, waveform/BPM guides, Finder override, artwork, real knob double-clicks, native faders, XML profiles, automatic controller detection/reconnection, actual CoreMIDI output and delayed LED initialization/shutdown. Native workspace tab switching preserves simultaneous playback. Automix tests exercise timed transitions, both playing decks during a fade, manual takeover, loop waiting, unavailable files and queue persistence without launch autoplay. + +Attached-interface checks verify available output devices and AUHAL stereo channel maps when the hardware is connected; that check skips when the interface is unavailable. Physical button response, visible LEDs and audible socket isolation still require hands-on verification. Builds compile for arm64 and x86_64; tests execute on Apple silicon. Temporary builds, logs, captures and scripts are under `/private/tmp/kjmix-xml-mappings`.