broken changes

This commit is contained in:
Mark Gregory
2026-11-05 12:51:29 +00:00
parent 528e10b43a
commit abee603250
11 changed files with 1076 additions and 55 deletions
+2
View File
@@ -263,3 +263,5 @@ releases/
/build/
/test-results/
/build-dj/
+4
View File
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
B10000000000000000000001 /* DJWorkstation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002; };
000000000000000000000065 /* KJMixProApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000001 /* KJMixProApp.swift */; };
000000000000000000000066 /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000002 /* Library.swift */; };
000000000000000000000067 /* CDG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000003 /* CDG.swift */; };
@@ -33,6 +34,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
B10000000000000000000002 /* DJWorkstation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkstation.swift; sourceTree = "<group>"; };
000000000000000000000001 /* KJMixProApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KJMixProApp.swift; sourceTree = "<group>"; };
000000000000000000000002 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = "<group>"; };
000000000000000000000003 /* CDG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CDG.swift; sourceTree = "<group>"; };
@@ -65,6 +67,7 @@
isa = PBXGroup;
children = (
000000000000000000000001 /* KJMixProApp.swift */,
B10000000000000000000002 /* DJWorkstation.swift */,
000000000000000000000002 /* Library.swift */,
000000000000000000000003 /* CDG.swift */,
000000000000000000000004 /* Importer.swift */,
@@ -184,6 +187,7 @@
buildActionMask = 2147483647;
files = (
000000000000000000000065 /* KJMixProApp.swift in Sources */,
B10000000000000000000001 /* DJWorkstation.swift in Sources */,
000000000000000000000066 /* Library.swift in Sources */,
000000000000000000000067 /* CDG.swift in Sources */,
000000000000000000000068 /* Importer.swift in Sources */,
+18 -20
View File
@@ -209,26 +209,24 @@ struct BackgroundDeckStrip: View {
@ObservedObject var deck: BackgroundDeck
private func stamp(_ seconds: Double) -> String { let value = Int(max(0,seconds));return String(format:"%d:%02d",value/60,value%60) }
var body: some View {
HStack(spacing:12) {
Image(systemName:"music.note").font(.title3).foregroundStyle(deck.playing ? .green : .secondary)
VStack(alignment:.leading,spacing:3) {
Text(deck.loaded ? deck.title : "Background Music").font(.system(size:12,weight:.semibold)).lineLimit(1)
Text(deck.status).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1)
if deck.loaded {
HStack(spacing:6) {
Text(stamp(deck.time)).monospacedDigit()
Slider(value:Binding(get:{deck.time},set:{deck.seek($0)}),in:0...max(0.001,deck.duration)).controlSize(.small).accessibilityLabel("Background track position").help("Drag to find the background track’s starting point; paused music stays paused")
Text(stamp(deck.duration)).monospacedDigit()
}.font(.system(size:10)).foregroundStyle(.secondary)
}
}.frame(maxWidth:.infinity,alignment:.leading)
Toggle("Auto",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})).toggleStyle(.checkbox).help("Start background music between singers; pause it before singer audio starts")
Button("Choose…") { model.chooseBackground() }.controlSize(.small)
Button { if deck.playing { model.pauseBackground() } else { model.playBackground() } } label: { Image(systemName:deck.playing ? "pause.fill" : "play.fill").frame(width:23) }.disabled(!deck.loaded).help(deck.playing ? "Pause background music" : "DJ: start or resume background music")
Button { model.stopBackground() } label: { Image(systemName:"stop.fill") }.disabled(!deck.loaded).help("Stop background and return to start")
Slider(value:$model.studio.backgroundTrim,in:0...1).frame(width:85).controlSize(.small).help("Background level relative to global master").accessibilityLabel("Background level")
Text("\(Int(model.studio.backgroundTrim*100))%").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:30)
}.padding(.horizontal,12).padding(.vertical,8).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9))
HStack(spacing:10) {
Image(systemName:"music.note.list").foregroundStyle(deck.playing ? .green : .secondary)
VStack(alignment:.leading,spacing:1) {
Text("BACKGROUND MUSIC").font(.system(size:8,weight:.semibold)).foregroundStyle(.secondary)
Text(deck.loaded ? deck.title : "Choose background music").font(.system(size:10,weight:.medium)).lineLimit(1).help(deck.title+" · "+deck.status)
}.frame(width:175,alignment:.leading)
if deck.loaded {
Text(stamp(deck.time)).font(.system(size:9).monospacedDigit()).foregroundStyle(.secondary)
Slider(value:Binding(get:{deck.time},set:{deck.seek($0)}),in:0...max(0.001,deck.duration)).controlSize(.mini).accessibilityLabel("Background track position").frame(minWidth:70)
Text(stamp(deck.duration)).font(.system(size:9).monospacedDigit()).foregroundStyle(.secondary)
} else { Spacer(minLength:0) }
Toggle("Auto",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})).toggleStyle(.checkbox).font(.system(size:10)).help("Start between singers and pause before singer audio")
Button("Choose…") { model.chooseBackground() }
Button { if deck.playing { model.pauseBackground() } else { model.playBackground() } } label: { Image(systemName:deck.playing ? "pause.fill" : "play.fill") }.disabled(!deck.loaded).accessibilityLabel("Background play pause")
Button { model.stopBackground() } label: { Image(systemName:"stop.fill") }.disabled(!deck.loaded).accessibilityLabel("Stop background music")
Slider(value:$model.studio.backgroundTrim,in:0...1).frame(width:65).accessibilityLabel("Background music level")
Text("\(Int(model.studio.backgroundTrim*100))%").font(.system(size:9).monospacedDigit()).foregroundStyle(.secondary).frame(width:26)
}.controlSize(.mini).padding(.horizontal,10).padding(.vertical,6).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:6))
}
}
+736
View File
@@ -0,0 +1,736 @@
import SwiftUI
import AVFoundation
import CoreAudio
import CoreMIDI
/// Equal-power crossfade, independent of the karaoke master and singer preferences.
enum DJMix {
static func weights(_ position: Double) -> (Double, Double) {
let x = position.isFinite ? min(1,max(0,position)) : 0.5
return (cos(x * .pi / 2),sin(x * .pi / 2))
}
static func syncRate(bpm: Double, otherBPM: Double, otherRate: Double) -> Double? {
guard bpm.isFinite,otherBPM.isFinite,otherRate.isFinite,bpm >= 40,bpm <= 240,otherBPM >= 40,otherBPM <= 240 else { return nil }
let result = otherBPM * otherRate / bpm
return (0.5...2).contains(result) ? result : nil
}
}
@MainActor final class DJChannel: ObservableObject {
let player = Player(persistsMix:false)
let letter: String
@Published private(set) var song: Song?
@Published var bpm: Double = 120
@Published var bpmConfirmed = false
@Published var gain: Double = 0 { didSet { player.inputGain = gain } }
@Published var level: Double = 0.8 { didSet { updateLevel() } }
@Published var loopBeats = 8
var master: Double = 0.8 { didSet { updateLevel() } }
var crossWeight: Double = 1 { didSet { updateLevel() } }
private var taps = [Double]()
init(_ letter: String) { self.letter = letter;player.autoFade = false;updateLevel() }
func updateLevel() { player.volume = min(1,max(0,level * master * crossWeight)) }
func load(_ song: Song) throws {
var audioOnly = song;audioOnly.cdg = nil
do { try player.load(audioOnly,startPlaying:false) } catch { self.song = nil;throw error }
self.song = song;bpmConfirmed = false;bpm = 120;taps = []
}
func unload() { player.stop();song = nil;bpmConfirmed = false }
func tap(at time: Double = ProcessInfo.processInfo.systemUptime) {
if let last = taps.last,time-last > 2 { taps = [] }
taps.append(time);taps = Array(taps.suffix(8))
guard taps.count >= 3,let first = taps.first,let last = taps.last,last > first else { return }
let value = 60 * Double(taps.count-1)/(last-first)
if (40...240).contains(value) { bpm = (value*10).rounded()/10;bpmConfirmed = true }
}
func beatLoop() {
if player.loopEnabled { player.clearLoop();return }
guard bpmConfirmed,player.canJog else { return }
let start = player.time,end = min(player.duration,start+Double(loopBeats)*60/bpm)
guard end-start >= 0.1 else { return }
let playing = player.playing
if playing { player.toggle() }
player.markLoopIn();player.seek(end);player.markLoopOut();player.seek(start)
if playing { player.toggle() }
}
}
struct DJAudioDevice: Identifiable, Equatable {
var id: AudioDeviceID
var uid: String
var name: String
static func outputs() -> [Self] {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size) == noErr else { return [] }
var ids = [AudioDeviceID](repeating:0,count:Int(size)/MemoryLayout<AudioDeviceID>.size)
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 }
func string(_ selector: AudioObjectPropertySelector) -> String? {
var property = AudioObjectPropertyAddress(mSelector:selector,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var text: Unmanaged<CFString>?;var bytes = UInt32(MemoryLayout<CFString?>.size)
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
return text?.takeRetainedValue() as String?
}
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output")
}
}
static func defaultOutput() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDefaultOutputDevice,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
var id = AudioDeviceID(0),size = UInt32(MemoryLayout<AudioDeviceID>.size)
return AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),&address,0,nil,&size,&id) == noErr && id != 0 ? id : nil
}
}
@MainActor final class DJSystem: ObservableObject {
let a = DJChannel("A"),b = DJChannel("B")
let preview = Player(persistsMix:false)
let midi = DJMIDI()
@Published var crossfader: Double = 0.5 { didSet { applyMix() } }
@Published var master: Double = 0.8 { didSet { applyMix() } }
@Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } }
@Published private(set) var previewDeck: String?
@Published var devices = [DJAudioDevice]()
@Published var masterUID = ""
@Published var cueUID = ""
@Published var status = "Load music into A or B. Karaoke keeps its own mixer and output."
@Published var error: String?
let root: URL
private(set) var library: Library?
let dragSession = UUID().uuidString
@Published var songs = [Song]()
@Published var playlists = [PersonPlace]()
@Published var importProgress: ImportProgress?
@Published private(set) var importing = false
private var importTask: Task<Void,Never>?
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/DJ")
preview.autoFade = false;preview.volume = cueLevel;refreshDevices();applyMix()
perform {
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
}
}
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name);try refresh() } }
func rename(_ table: String,id: Int64,name: String) { perform { try library?.rename(table,id:id,name:name);try refresh() } }
func delete(_ table: String,id: Int64) { perform { try library?.deleteEntity(table,id:id);try refresh() } }
func addToPlaylist(_ playlist: Int64,song: Int64) { perform { try library?.addToPlaylist(playlist,song:song);try refresh() } }
func dragReference(_ song: Int64) -> SongDragReference { .init(song:song,library:dragSession,singer:nil,venue:nil,fromTonight:false) }
func importFiles(_ urls: [URL]) {
guard !importing,!urls.isEmpty else { return }
importing = true;importProgress = .init()
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media")
importTask = Task.detached(priority:.utility) { [weak self] in
let access = urls.filter{$0.startAccessingSecurityScopedResource()}
defer { for url in access { url.stopAccessingSecurityScopedResource() } }
var latest = ImportProgress()
do {
let db = try Library(url:database)
try Importer(media:media,audioExtensions:["mp3","wav","m4a","mp4","m4v","mov","aiff","aif","caf","flac","aac"],includeGraphics:false).run(urls,library:db,cancelled:{Task.isCancelled}) { value in
latest = value
Task { @MainActor [weak self] in if self?.importing == true { self?.importProgress = value } }
}
} catch is CancellationError { latest.cancelled = true;latest.current = "DJ import cancelled" }
catch { latest.failure = error.localizedDescription;latest.current = "DJ import stopped" }
latest.finished = true;let result = latest
await MainActor.run { [weak self] in
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 }
}
}
}
func cancelImport() { importTask?.cancel() }
deinit { importTask?.cancel() }
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
func applyMix() { let weights = DJMix.weights(crossfader);a.master = master;b.master = master;a.crossWeight = weights.0;b.crossWeight = weights.1 }
func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b }
func load(_ song: Song,into letter: String) {
perform {
let deck = channel(letter)
// Silence first; loading never starts public playback.
if previewDeck == letter { stopPreview() }
try deck.load(song)
if !masterUID.isEmpty {
do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error }
}
status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops"
}
}
func refreshDevices() { devices = DJAudioDevice.outputs();midi.refreshSources() }
private func device(_ uid: String) throws -> AudioDeviceID {
if uid.isEmpty,let id = DJAudioDevice.defaultOutput() { return id }
guard let output = devices.first(where:{$0.uid == uid}) else { throw LibraryError.message("Selected audio output is disconnected. Refresh and choose an available output.") }
return output.id
}
func routeMaster(_ uid: String) {
let old = masterUID
perform {
let id = try device(uid)
stopPreview()
do { try a.player.routeOutput(id);try b.player.routeOutput(id);masterUID = uid }
catch { if let prior = try? device(old) { try? a.player.routeOutput(prior);try? b.player.routeOutput(prior) };throw error }
}
}
func stopPreview() { preview.stop();previewDeck = nil }
func startPreview(_ deck: DJChannel) {
if previewDeck == deck.letter { stopPreview();return }
perform {
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a separate headphone output in Audio Routing first.") }
guard let song = deck.song else { throw LibraryError.message("Load a track first.") }
let output = try device(cueUID)
guard output != (try device(masterUID)) else { throw LibraryError.message("Headphone preview requires a different output device from the master. Multichannel pairs on one device are not yet supported.") }
stopPreview();var audio = song;audio.cdg = nil
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
status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position"
}
}
func sync(_ deck: DJChannel) {
let other = deck.letter == "A" ? b : a
guard deck.bpmConfirmed,other.bpmConfirmed,let rate = DJMix.syncRate(bpm:deck.bpm,otherBPM:other.bpm,otherRate:other.player.tempo) else { status = "Set or tap both BPM values first (40–240). Sync range: 50–200%.";return }
deck.player.tempo = rate;status = "\(deck.letter) tempo matched to \(other.letter). Align beats with jog; no automatic beat grid."
}
func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) {
if name == "Crossfader" { crossfader = min(1,max(0,value));return }
if name == "Master" { master = 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] {
case "Play": p.toggle()
case "Stop": p.stopDeck()
case "Cue": p.returnToCue()
case "Set cue": p.setCue()
case "Sync": sync(d)
case "Loop": d.beatLoop()
case "Headphones": startPreview(d)
case "Load": if let selected { load(selected,into:d.letter) }
case "Gain": d.gain = value*24-12
case "High": p.highGain = value*24-12
case "Mid": p.midGain = value*24-12
case "Low": p.lowGain = value*24-12
case "Filter": p.filter = value*2-1
case "Level": d.level = value
case "Tempo": p.tempo = 0.84+value*0.32
case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2)
case "Hot cue 1": p.hotCue(0)
case "Hot cue 2": p.hotCue(1)
case "Hot cue 3": p.hotCue(2)
case "Hot cue 4": p.hotCue(3)
default: break
}
}
}
// Streaming MIDI 1.0 decoder: preserves running status across packets; ignores clock and SysEx.
struct DJMIDIMessage: Equatable { var status: UInt8;var data1: UInt8;var data2: UInt8 }
struct DJMIDIDecoder {
private var status: UInt8 = 0
private var data = [UInt8]()
mutating func feed(_ bytes: [UInt8]) -> [DJMIDIMessage] {
var result = [DJMIDIMessage]()
for byte in bytes {
if byte >= 0xF8 { continue }
if byte & 0x80 != 0 { status = byte < 0xF0 ? byte : 0;data = [];continue }
guard status != 0 else { continue };data.append(byte)
let count = [0xC0,0xD0].contains(status & 0xF0) ? 1 : 2
if data.count == count {
if [0x80,0x90,0xB0].contains(status & 0xF0) { result.append(.init(status:status,data1:data[0],data2:data.count > 1 ? data[1] : 0)) }
data = []
}
}
return result
}
}
struct DJMIDIMapping: Codable, Identifiable, Equatable {
var source: Int32;var status: UInt8;var number: UInt8;var action: String;var mode: String
var id: String { "\(source):\(status):\(number)" }
static func value(_ byte: UInt8,mode: String) -> Double {
if mode == "Relative signed" { return byte < 64 ? Double(byte) : Double(Int(byte)-128) }
if mode == "Relative offset" { return Double(Int(byte)-64) }
return Double(byte)/127
}
}
@MainActor final class DJMIDI: ObservableObject {
struct Source: Identifiable { var id: Int32;var endpoint: MIDIEndpointRef;var name: String }
@Published var sources = [Source]()
@Published private(set) var sourceID: Int32 = 0
@Published var mappings = [DJMIDIMapping]()
@Published var learning: String?
@Published var learnMode = "Absolute"
@Published var status = "Choose a MIDI source; learn each control. No assumed factory mapping."
var onAction: ((String,Double,Bool) -> Void)?
private var client = MIDIClientRef(0),port = MIDIPortRef(0),connected = MIDIEndpointRef(0)
private var decoder = DJMIDIDecoder()
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard,connect: Bool = true) {
self.defaults = defaults
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 }
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.
var packet = UnsafeRawPointer(list).advanced(by:MemoryLayout<MIDIPacketList>.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self)
for _ in 0..<list.pointee.numPackets {
let data = UnsafeRawPointer(packet).advanced(by:MemoryLayout<MIDIPacket>.offset(of: \.data)!)
bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length)))
packet = UnsafePointer(MIDIPacketNext(packet))
}
let received = bytes
Task { @MainActor in self?.receive(received) }
}
if input != noErr { status = "MIDI input unavailable (\(input))" }
refreshSources()
}
func refreshSources() {
sources = (0..<MIDIGetNumberOfSources()).map { index in
let endpoint = MIDIGetSource(index);var uid: Int32 = 0;var name: Unmanaged<CFString>?
MIDIObjectGetIntegerProperty(endpoint,kMIDIPropertyUniqueID,&uid)
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." }
}
func select(_ id: Int32) {
if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil
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)" }
else { status = "Could not connect MIDI source (\(result))" }
}
func receive(_ bytes: [UInt8]) {
for message in decoder.feed(bytes) {
let type = message.status & 0xF0
guard type == 0xB0 || (type == 0x90 && message.data2 > 0) else { continue }
if let action = learning {
let mapping = DJMIDIMapping(source:sourceID,status:message.status,number:message.data1,action:action,mode:action.hasSuffix(" Jog") ? learnMode : "Absolute")
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
}
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)
if !continuous && message.data2 == 0 { continue }
onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute")
}
}
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) } }
}
private enum DJStyle {
static let background = Color(red:0.055,green:0.065,blue:0.085)
static let panel = Color(red:0.095,green:0.11,blue:0.14)
static let a = Color(red:0.20,green:0.80,blue:0.95)
static let b = Color(red:1.0,green:0.66,blue:0.22)
static func color(_ deck: String) -> Color { deck == "A" ? a : b }
static func stamp(_ value: Double) -> String {
guard value.isFinite else { return "0:00" };let seconds = Int(max(0,value));return String(format:"%d:%02d",seconds/60,seconds%60)
}
}
struct DJConsole: View {
@ObservedObject var model: AppModel
@ObservedObject var system: DJSystem
@State private var routing = false
@State private var mapping = false
@State private var query = ""
@State private var playlist: Int64?
@State private var playlistIDs = [Int64]()
@State private var selected: Int64?
@State private var playlistEditor = false
@State private var playlistName = ""
@State private var renaming = false
@State private var page = 0
private var songs: [Song] {
let all = system.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}
if playlist != nil { let lookup = Dictionary(uniqueKeysWithValues:all.map{($0.id,$0)});return playlistIDs.compactMap{lookup[$0]} }
return all
}
private var selectedSong: Song? { system.songs.first{$0.id == selected} }
private var pages: Int { max(1,(songs.count+49)/50) }
private var visible: [Song] { Array(songs.dropFirst(min(page,pages-1)*50).prefix(50)) }
var body: some View {
ScrollView {
VStack(spacing:10) {
header
HStack(alignment:.top,spacing:20) {
DJDeckPanel(deck:system.a,system:system,loadSelected:{loadSelected("A")})
mixer.frame(width:105)
DJDeckPanel(deck:system.b,system:system,loadSelected:{loadSelected("B")})
}
browser.frame(height:130)
if let progress = system.importProgress {
HStack {
Text(progress.current).lineLimit(1);Text("\(progress.completed)/\(progress.total)").monospacedDigit()
Spacer()
if system.importing { ProgressView().controlSize(.mini);Button("Cancel") { system.cancelImport() } }
else { Button("Dismiss") { system.importProgress = nil } }
}.font(.caption).controlSize(.mini)
}
HStack { Text(system.status).lineLimit(1);Spacer();Text("DJ master separate from karaoke").foregroundStyle(.secondary) }.font(.system(size:10))
}.padding(14)
}.background(DJStyle.background).foregroundStyle(.white)
.preferredColorScheme(.dark)
.environment(\.djResolveSong) { reference in
guard reference.library == system.dragReference(reference.song).library else { return nil }
return system.songs.first{$0.id == reference.song && !$0.audio.hasPrefix("showscreen:")}
}
.sheet(isPresented:$routing) { DJRoutingSheet(model:model,system:system) }
.sheet(isPresented:$mapping) { DJMappingSheet(midi:system.midi) }
.sheet(isPresented:$playlistEditor) { playlistSheet }
.alert("DJ workstation",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") }
.onAppear { refreshPlaylist();connectMIDI() }
.onChange(of:selected) { _ in connectMIDI() }
.onChange(of:playlist) { _ in refreshPlaylist();page = 0;selected = nil }
.onChange(of:system.songs) { _ in refreshPlaylist();connectMIDI() }
.onChange(of:system.playlists) { _ in if let playlist,!system.playlists.contains(where:{$0.id == playlist}) { self.playlist = nil };refreshPlaylist() }
.onChange(of:query) { _ in page = 0 }
}
private var header: some View {
HStack {
Image(systemName:"waveform.path").foregroundStyle(DJStyle.a)
Text("DJ WORKSTATION").font(.system(size:12,weight:.bold,design:.rounded))
Text("TWO DECKS").font(.system(size:9,weight:.medium)).foregroundStyle(.secondary)
Spacer()
if system.previewDeck != nil { Button("Stop headphone preview") { system.stopPreview() }.tint(.green) }
Button { routing = true } label: { Label("Audio Routing",systemImage:"hifispeaker.fill") }
Button { mapping = true } label: { Label("MIDI",systemImage:"pianokeys") }
}.controlSize(.small)
}
private var mixer: some View {
VStack(spacing:10) {
Text("MASTER").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary)
DJKnob(label:"Level",value:$system.master,range:0...1,reset:0.8,color:.green,format:{String(format:"%.0f%%",$0*100)})
DJOutputMeter(a:system.a.player,b:system.b.player)
Text("CROSSFADER").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary)
Slider(value:$system.crossfader,in:0...1).accessibilityLabel("DJ crossfader").tint(.white)
HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("•") { system.crossfader = 0.5 }.buttonStyle(.plain).help("Centre crossfader");Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.caption.bold())
Button("Stop both") { system.a.player.stopDeck();system.b.player.stopDeck();system.stopPreview() }.controlSize(.small)
}.padding(.top,8)
}
private var browser: some View {
VStack(spacing:5) {
HStack(spacing:8) {
Picker("DJ library",selection:$playlist) { Text("All music").tag(nil as Int64?);ForEach(system.playlists) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:175)
Menu {
Button("New Playlist…") { renaming = false;playlistName = "";playlistEditor = true }
if let playlist {
Button("Rename Playlist…") { renaming = true;playlistName = system.playlists.first{$0.id == playlist}?.name ?? "";playlistEditor = true }
Button("Delete Playlist",role:.destructive) { system.delete("playlists",id:playlist) }
}
} label: { Image(systemName:"folder.badge.plus") }.frame(width:25)
TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder)
Button("Import Files / Folder…") { importMedia() }
Spacer(minLength:0)
Button { page = max(0,page-1) } label: { Image(systemName:"chevron.left") }.disabled(page == 0)
Text("\(min(page,pages-1)+1)/\(pages) · \(songs.count)").font(.caption.monospacedDigit()).fixedSize()
Button { page = min(pages-1,page+1) } label: { Image(systemName:"chevron.right") }.disabled(page >= pages-1)
}.controlSize(.small)
List(selection:$selected) {
ForEach(visible) { song in
HStack(spacing:8) {
Image(systemName:"music.note").foregroundStyle(.secondary)
Text(song.title).font(.system(size:11,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("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")
}.tag(song.id).buttonStyle(.bordered).controlSize(.mini)
.onDrag { system.dragReference(song.id).provider() }
.contextMenu { playlistMenu(song) }
}
if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) }
}.listStyle(.inset).scrollContentBackground(.hidden).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:6))
}
}
@ViewBuilder private func playlistMenu(_ song: Song) -> some View {
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() }
Button("Move Later") { system.perform { try system.library?.reorderPlaylist(playlist,song:song.id,by:1) };refreshPlaylist() }
Button("Remove from Playlist") { system.perform { try system.library?.removeFromPlaylist(playlist,song:song.id) };refreshPlaylist() }
}
}
private var playlistSheet: some View {
VStack(alignment:.leading,spacing:16) {
Text(renaming ? "Rename Playlist" : "New Playlist").font(.title2)
TextField("Playlist name",text:$playlistName).textFieldStyle(.roundedBorder)
HStack { Button("Cancel") { playlistEditor = false };Spacer();Button("Save") {
let name = playlistName.trimmingCharacters(in:.whitespacesAndNewlines)
if renaming,let playlist { system.rename("playlists",id:playlist,name:name) } else { system.createPlaylist(name) }
playlistEditor = false
}.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty).keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:360)
}
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) }
}
private func importMedia() {
let panel = NSOpenPanel();panel.title = "Import DJ Music · Files or Folders";panel.canChooseFiles = true;panel.canChooseDirectories = true;panel.allowsMultipleSelection = true
if panel.runModal() == .OK { system.importFiles(panel.urls) }
}
}
private struct DJDeckPanel: View {
@ObservedObject var deck: DJChannel
@ObservedObject var system: DJSystem
let loadSelected: () -> Void
var body: some View { DJDeckFace(deck:deck,player:deck.player,system:system,loadSelected:loadSelected) }
}
private struct DJDeckFace: View {
@ObservedObject var deck: DJChannel
@ObservedObject var player: Player
@ObservedObject var system: DJSystem
let loadSelected: () -> Void
private var color: Color { DJStyle.color(deck.letter) }
var body: some View {
VStack(spacing:7) {
HStack {
Text("DECK \(deck.letter)").font(.system(size:11,weight:.bold)).foregroundStyle(color)
Text(player.playing ? "PLAYING" : player.canJog ? "READY" : "EMPTY").font(.system(size:8,weight:.bold)).foregroundStyle(player.playing ? .green : .secondary)
Spacer()
Button("Load \(deck.letter)",action:loadSelected).controlSize(.mini).help("Load the selected DJ library track, paused")
}
HStack {
VStack(alignment:.leading,spacing:2) { Text(deck.song?.title ?? "Import and load a DJ track").font(.system(size:12,weight:.semibold)).lineLimit(1);Text(deck.song?.artist ?? "Separate DJ music library").font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1) }
Spacer();Text("\(DJStyle.stamp(player.time)) / \(DJStyle.stamp(player.duration))").font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary)
}
DJWaveform(player:player,color:color).frame(height:36)
HStack(spacing:28) {
if deck.letter == "B" { tonePanel }
DJPlatter(player:player,letter:deck.letter,color:color).frame(width:105,height:105)
if deck.letter == "A" { tonePanel }
}.frame(maxWidth:.infinity).padding(.vertical,3)
transport
}.padding(12).background(DJStyle.panel,in:RoundedRectangle(cornerRadius:8))
.overlay(alignment:.top) { RoundedRectangle(cornerRadius:2).fill(color).frame(height:2).padding(.horizontal,8) }
.onDrop(of:["public.text"],isTargeted:nil) { providers in
guard let first = providers.first else { return false }
Task { @MainActor in
do {
let reference = try await SongDragReference.load(first)
// Match the current library's drag session before accepting an internal reference.
guard let song = systemLibrarySong(reference) else { system.error = "Import this track into the DJ library first, then drag it from the DJ browser.";return }
system.load(song,into:deck.letter)
} catch { system.error = error.localizedDescription }
}
return true
}
}
// Supplied by the library via a small environment closure, avoiding paths in drag payloads.
@Environment(\.djResolveSong) private var resolveSong
private func systemLibrarySong(_ reference: SongDragReference) -> Song? { resolveSong(reference) }
private var tonePanel: some View {
HStack(spacing:10) {
VStack(spacing:5) {
DJKnob(label:"HIGH",value:$player.highGain,range:-12...12,reset:0,color:color)
DJKnob(label:"MID",value:$player.midGain,range:-12...12,reset:0,color:color)
DJKnob(label:"LOW",value:$player.lowGain,range:-12...12,reset:0,color:color)
}
VStack(spacing:5) {
DJKnob(label:"GAIN",value:$deck.gain,range:-12...12,reset:0,color:color)
DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)})
DJKnob(label:"LEVEL",value:$deck.level,range:0...1,reset:0.8,color:color,format:{String(format:"%.0f%%",$0*100)})
}
}.frame(width:100)
}
private var transport: some View {
VStack(spacing:5) {
HStack(spacing:5) {
Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").frame(width:20) }.tint(player.playing ? .green : color).accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play")
Button("Cue") { player.returnToCue() }.tint(.orange)
Button("Set") { player.setCue() }.help("Set cue at current position")
Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.help("Stop and return to start")
Button("Sync") { system.sync(deck) }.help("Match tempo to the other deck using confirmed BPM; jog to align beats")
Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.tint(system.previewDeck == deck.letter ? .green : .gray).help("Independent headphone preview on a separate output")
Spacer(minLength:0)
Button("Tap") { deck.tap() }.help("Tap at least three beats to set source BPM")
TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true}),format:.number.precision(.fractionLength(1))).frame(width:45).textFieldStyle(.roundedBorder).accessibilityLabel("Deck \(deck.letter) source BPM")
Text(deck.bpmConfirmed ? "BPM" : "BPM?").font(.system(size:8)).foregroundStyle(.secondary)
}.disabled(!player.canJog)
HStack(spacing:5) {
ForEach(0..<4) { i in
Button("\(i+1)") { player.hotCue(i) }.tint(player.hotCues[i] == nil ? .gray : color).contextMenu { Button("Clear hot cue") { player.hotCue(i,clear:true) } }.help("Hot cue \(i+1): click to set or jump; right-click to clear")
}
Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() }
Button(player.loopEnabled ? "Exit Loop" : "Loop") { if player.loopEnabled { player.clearLoop() } else { deck.beatLoop() } }.tint(player.loopEnabled ? .green : .gray).help("Beat loop uses confirmed BPM; manual IN/OUT works without BPM")
Picker("Loop beats",selection:$deck.loopBeats) { ForEach([1,2,4,8,16,32],id:\.self) { Text("\($0)").tag($0) } }.labelsHidden().frame(width:48)
Spacer(minLength:0)
Button("−") { player.jog(-0.05) };Button("+") { player.jog(0.05) }
}.disabled(!player.canJog)
HStack(spacing:5) {
Text("TEMPO").font(.system(size:8)).foregroundStyle(.secondary)
Slider(value:$player.tempo,in:0.5...2).tint(color).accessibilityLabel("Deck \(deck.letter) tempo")
Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).help("Reset tempo")
}
}.buttonStyle(.bordered).controlSize(.mini)
}
}
private struct DJResolveKey: EnvironmentKey { static let defaultValue: (SongDragReference) -> Song? = { _ in nil } }
private extension EnvironmentValues { var djResolveSong: (SongDragReference) -> Song? { get { self[DJResolveKey.self] } set { self[DJResolveKey.self] = newValue } } }
private struct DJKnob: View {
let label: String
@Binding var value: Double
let range: ClosedRange<Double>
let reset: Double
let color: Color
var format: (Double) -> String = {String(format:"%+.0f dB",$0)}
@State private var initial: Double?
private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) }
var body: some View {
VStack(spacing:1) {
ZStack {
Circle().fill(Color.black.opacity(0.35)).overlay { Circle().stroke(.white.opacity(0.12),lineWidth:2) }
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())
.gesture(DragGesture(minimumDistance:0).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})
.onTapGesture(count:2) { value = reset }
Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary)
}.frame(minWidth:36).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))) }
}
}
private struct DJPlatter: View {
@ObservedObject var player: Player
let letter: String
let color: Color
@State private var lastAngle: Double?
var body: some View {
GeometryReader { geometry in
ZStack {
Circle().fill(Color.black.opacity(0.55)).overlay { Circle().stroke(color.opacity(0.4),lineWidth:3) }
ForEach([0.70,0.80,0.90],id:\.self) { scale in Circle().stroke(.white.opacity(0.06),lineWidth:1).scaleEffect(scale) }
ZStack {
Circle().fill(color.opacity(0.15)).frame(width:53,height:53)
Text(letter).font(.system(size:25,weight:.bold,design:.rounded)).foregroundStyle(color)
Capsule().fill(color).frame(width:3,height:12).offset(y:-43)
}.rotationEffect(.degrees(player.time*200))
}.contentShape(Circle())
.gesture(DragGesture(minimumDistance:2).onChanged { drag in
let angle = atan2(Double(drag.location.y-geometry.size.height/2),Double(drag.location.x-geometry.size.width/2))
if let previous = lastAngle { var delta = angle-previous;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta / (2 * .pi) * 2) }
lastAngle = angle
}.onEnded{_ in lastAngle = nil})
}.accessibilityLabel("Deck \(letter) jog platter").accessibilityValue(player.playing ? "Rotating" : "Paused").help("Drag around the platter to jog (2 seconds per turn). Rotation follows source playback time.")
}
}
private struct DJWaveform: View {
@ObservedObject var player: Player
let color: Color
var body: some View {
GeometryReader { geometry in
Canvas { context,size in
let values = player.waveform
guard !values.isEmpty else { return }
let count = max(1,Int(size.width/2))
for i in 0..<count {
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)))
}
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))
.overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } }
.contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in player.seek(Double(value.location.x/max(1,geometry.size.width))*player.duration) })
}.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) }
}
}
private struct DJOutputMeter: View {
@ObservedObject var a: Player
@ObservedObject var b: Player
var body: some View {
VStack(spacing:3) {
Text("DECK PEAKS").font(.system(size:8)).foregroundStyle(.secondary)
ForEach(0..<2) { index in
let level = Double(index == 0 ? max(a.leftLevel,a.rightLevel) : max(b.leftLevel,b.rightLevel))
GeometryReader { geometry in ZStack(alignment:.leading) { Rectangle().fill(.white.opacity(0.06));Rectangle().fill(level > 0.95 ? .red : .green).frame(width:geometry.size.width*min(1,max(0,level))) } }.frame(height:5)
}
}.accessibilityLabel("Deck output peak meters")
}
}
private struct DJRoutingSheet: View {
@ObservedObject var model: AppModel
@ObservedObject var system: DJSystem
@State private var karaokeUID = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment:.leading,spacing:18) {
HStack { Text("Audio Routing").font(.title2.bold());Spacer();Button("Refresh Devices") { system.refreshDevices() } }
Picker("DJ master",selection:Binding(get:{system.masterUID},set:{system.routeMaster($0)})) {
Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) }
}
Picker("Headphone preview",selection:Binding(get:{system.cueUID},set:{system.stopPreview();system.cueUID = $0})) {
Text("Not assigned").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) }
}
HStack { Text("Headphone level");Slider(value:$system.cueLevel,in:0...1) }
Picker("Karaoke output",selection:$karaokeUID) {
Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) }
}.onChange(of:karaokeUID) { uid in
system.perform {
guard let id = uid.isEmpty ? DJAudioDevice.defaultOutput() : system.devices.first(where:{$0.uid == uid})?.id else { throw LibraryError.message("Karaoke output unavailable") }
try model.player.routeOutput(id)
}
}
Text("Headphone preview starts independently at the selected deck’s current position, before its fader and crossfader. Use a different output device from DJ master. It does not follow later deck seeks, EQ or tempo changes; restart preview to refresh.").font(.callout).foregroundStyle(.secondary)
Text("Each device uses its default stereo pair. Separate master/cue channel pairs on one multichannel controller, aggregate-device setup and phase-locked cue monitoring are not implemented. Background music keeps its existing system output. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary)
Text("DJ music can play alongside karaoke. Stop the DJ decks when a singer starts if you do not want both audible.").font(.caption)
HStack { Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:590).onAppear { system.refreshDevices() }
}
}
private struct DJMappingSheet: View {
@ObservedObject var midi: DJMIDI
@State private var action = "A Play"
@Environment(\.dismiss) private var dismiss
private let commands = ["Play","Stop","Cue","Set cue","Sync","Loop","Headphones","Load","Gain","High","Mid","Low","Filter","Level","Tempo","Jog","Hot cue 1","Hot cue 2","Hot cue 3","Hot cue 4"]
private var actions: [String] { ["Crossfader","Master"]+["A","B"].flatMap{deck in commands.map{deck+" "+$0}} }
var body: some 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 {
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)
}
HStack {
Button(midi.learning == nil ? "Learn Next Control" : "Cancel Learning") { if midi.learning == nil { midi.learning = action } else { midi.learning = nil } }.disabled(midi.sourceID == 0)
if let learning = midi.learning { Text("Move or press the control for \(learning)…").foregroundStyle(.orange) }
}
Text(midi.status).font(.caption)
List {
ForEach(midi.mappings.filter{$0.source == midi.sourceID}) { map in
HStack {
Text(map.action).frame(width:110,alignment:.leading)
Text("\(map.status & 0xF0 == 0xB0 ? "CC" : "Note") \(map.number) · ch \((map.status & 0x0F)+1)").foregroundStyle(.secondary)
Spacer();Text(map.mode).font(.caption).foregroundStyle(.secondary)
Button { midi.remove(map.id) } label: { Image(systemName:"trash") }.buttonStyle(.borderless).accessibilityLabel("Remove mapping for \(map.action)")
}
}
}.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)
HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) }
}.padding(24).frame(width:660)
}
}
+56 -8
View File
@@ -1,6 +1,7 @@
import Foundation
import CryptoKit
import AVFoundation
import Darwin
struct ImportProgress: Sendable {
var completed = 0
@@ -14,6 +15,8 @@ struct ImportProgress: Sendable {
struct Importer {
let media: URL
var audioExtensions: Set<String> = ["mp3","wav","m4a","mp4","m4v","mov"]
var includeGraphics = true
@discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false },progress: (ImportProgress) -> Void = { _ in }) throws -> [Int64] {
try ManagedMediaStorage.prepare(media)
var state = ImportProgress()
@@ -42,7 +45,7 @@ struct Importer {
// Discover before copying: overlapping selections must not import twice,
// and a destination inside the selected tree must not feed itself back in.
let keys: Set<URLResourceKey> = [.isDirectoryKey,.isRegularFileKey,.isSymbolicLinkKey,.isPackageKey]
let supported = Set(["mp3","wav","m4a","mp4","m4v","mov","cdg","zip"])
let supported = audioExtensions.union(includeGraphics ? ["cdg","zip"] : ["zip"])
let managedRoot = media.resolvingSymlinksInPath().standardizedFileURL
var visited = Set<URL>(),discovered = Set<URL>()
func discover(_ input:URL) throws {
@@ -154,19 +157,20 @@ struct Importer {
if values.isRegularFile == true { files.append(url);archiveForFile[url.resolvingSymlinksInPath().path] = folder }
}
}
for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) {
for cdg in files.filter({ includeGraphics && $0.pathExtension.lowercased() == "cdg" }) {
if cancelled() { throw CancellationError() }
let matching = try companions(of:cdg)
if let audio = ["mp3","wav","m4a","mp4","m4v","mov"].compactMap({ matching[$0] }).first { files.append(audio) }
if let audio = (["mp3","wav","m4a","mp4","m4v","mov"]+audioExtensions.subtracting(["mp3","wav","m4a","mp4","m4v","mov"]).sorted()).compactMap({ matching[$0] }).first { files.append(audio) }
}
var imported = 0
let audioFiles = Set(files.map { $0.standardizedFileURL }).filter { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path }
let audioFiles = Set(files.map { $0.standardizedFileURL }).filter { audioExtensions.contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path }
state.total = audioFiles.count+state.completed
report("Importing tracks…",force:true)
for audio in audioFiles {
if cancelled() { throw CancellationError() }
report(audio.lastPathComponent)
let cdg = try companions(of:audio)["cdg"]
let cdg: URL?
if includeGraphics { cdg = try companions(of:audio)["cdg"] } else { cdg = nil }
let archive = archiveForFile[audio.resolvingSymlinksInPath().path]
let copy = copyMedia || archive != nil
let signature = try importSignature(audio,cdg:cdg)
@@ -176,6 +180,7 @@ struct Importer {
state.completed += 1;state.skipped += 1
report("Already imported: "+audio.lastPathComponent,force:state.completed == 1);continue
}
guard MediaByteCopy.nonempty(audio) else { throw LibraryError.message("Audio file is empty or unreadable: "+audio.path) }
let hash = try audioFingerprint(audio,cancelled:cancelled)
let graphicsHash = try cdg.map { try audioFingerprint($0,cancelled:cancelled) }
let duplicate = try library.songWithFingerprint(hash)
@@ -183,18 +188,18 @@ struct Importer {
if let graphicsHash,let stored = duplicate?.cdg,FileManager.default.fileExists(atPath:stored) {
sameGraphics = try audioFingerprint(URL(fileURLWithPath:stored),cancelled:cancelled) == graphicsHash
}
if let existing = duplicate,sameGraphics,FileManager.default.fileExists(atPath:existing.audio),
if let existing = duplicate,sameGraphics,MediaByteCopy.nonempty(URL(fileURLWithPath:existing.audio)),
(!copy || URL(fileURLWithPath:existing.audio).deletingLastPathComponent().standardizedFileURL == media.standardizedFileURL) {
try remember(audio,signature,existing.id);songIDs.insert(existing.id)
state.completed += 1;state.skipped += 1
report("Already in library: "+audio.lastPathComponent,force:state.completed == 1);continue
}
let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL
if copy && !FileManager.default.fileExists(atPath:dest.path) { try FileManager.default.copyItem(at:audio,to:dest) }
if copy { try MediaByteCopy.copy(audio,to:dest,expectedHash:hash,cancelled:cancelled) }
var graphics: String?
if let cdg {
let target = copy ? media.appendingPathComponent(hash+"-"+(graphicsHash ?? "graphics")+".cdg") : cdg.standardizedFileURL
if copy && !FileManager.default.fileExists(atPath:target.path) { try FileManager.default.copyItem(at:cdg,to:target) }
if copy { try MediaByteCopy.copy(cdg,to:target,expectedHash:graphicsHash!,cancelled:cancelled) }
graphics = target.path
}
imported += 1
@@ -387,3 +392,46 @@ enum ManagedMediaStorage {
progress(state);return state
}
}
/// Copy media content, not source ACLs, flags or private security-scope attributes.
/// A failed/cancelled transfer never publishes a partial destination.
enum MediaByteCopy {
static func nonempty(_ url: URL) -> Bool {
guard let values = try? url.resourceValues(forKeys:[.fileSizeKey,.isRegularFileKey]),values.isRegularFile == true else { return false }
return (values.fileSize ?? 0) > 0
}
static func digest(_ url: URL,cancelled: () -> Bool = { false }) throws -> String {
let input = try FileHandle(forReadingFrom:url);defer { try? input.close() }
var hash = SHA256()
while true {
if cancelled() { throw CancellationError() }
guard let bytes = try input.read(upToCount:1_048_576),!bytes.isEmpty else { break }
hash.update(data:bytes)
}
return hash.finalize().map{String(format:"%02x",$0)}.joined()
}
static func copy(_ source: URL,to destination: URL,expectedHash: String,cancelled: () -> Bool = { false }) throws {
if cancelled() { throw CancellationError() }
if nonempty(destination),try digest(destination,cancelled:cancelled) == expectedHash { return }
let temporary = destination.deletingLastPathComponent().appendingPathComponent(".kjmix-import-"+UUID().uuidString)
defer { try? FileManager.default.removeItem(at:temporary) }
do {
let input = try FileHandle(forReadingFrom:source);defer { try? input.close() }
guard FileManager.default.createFile(atPath:temporary.path,contents:nil,attributes:[.posixPermissions:0o644]) else { throw LibraryError.message("Cannot create a file in "+destination.deletingLastPathComponent().path) }
let output = try FileHandle(forWritingTo:temporary);defer { try? output.close() }
var bytesWritten = 0
while true {
if cancelled() { throw CancellationError() }
guard let data = try input.read(upToCount:1_048_576),!data.isEmpty else { break }
try output.write(contentsOf:data);bytesWritten += data.count
}
try output.synchronize();try output.close()
guard bytesWritten > 0,try digest(temporary,cancelled:cancelled) == expectedHash else { throw LibraryError.message("Audio data was empty or changed during copying. Retry the import.") }
if cancelled() { throw CancellationError() }
// Atomic same-directory replacement also repairs a previous zero-byte copy.
let result = temporary.withUnsafeFileSystemRepresentation { from in destination.withUnsafeFileSystemRepresentation { to in Darwin.rename(from!,to!) } }
guard result == 0 else { throw NSError(domain:NSPOSIXErrorDomain,code:Int(errno)) }
} catch is CancellationError { throw CancellationError() }
catch { throw LibraryError.message("Could not import \(source.lastPathComponent) from \(source.deletingLastPathComponent().path) into \(destination.deletingLastPathComponent().path): \(error.localizedDescription)") }
}
}
+20 -16
View File
@@ -65,9 +65,7 @@ struct SongDragReference: Codable, Sendable {
@Published var activeSection = "Library"
@Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } }
let backgroundDeck = BackgroundDeck()
#if KJMIX_DJ_CONSOLE
let dj = DJSystem()
#endif
let dj: DJSystem
var backgroundAutomationReady = false
var backgroundPlaybackObserver: AnyCancellable?
var backgroundHandoff: Task<Void,Never>?
@@ -123,6 +121,7 @@ struct SongDragReference: Codable, Sendable {
var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" }
init(storageURL: URL? = nil) {
root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone")
dj = DJSystem(storageURL:root.appendingPathComponent("DJ"))
managedMediaDirectory = root.appendingPathComponent("Media")
player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
perform {
@@ -673,6 +672,8 @@ private struct TransportButton: View {
}
struct LibraryView: View {
@ObservedObject var model: AppModel
@State var showDJ = true
@State private var djSongSettings = false
@State private var selected: Int64?
@State private var tableSelection = Set<Int64>()
@State private var waveformDetails = false
@@ -723,9 +724,11 @@ struct LibraryView: View {
HSplitView {
sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260)
VStack(spacing:0) {
BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,6)
Divider()
TransportView(player:model.player,model:model,search:$search,hasSelection:selectedSong != nil,
play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") })
.padding(20)
.padding(showDJ ? 12 : 20)
Divider()
HStack {
VStack(alignment:.leading,spacing:4) {
@@ -733,10 +736,14 @@ struct LibraryView: View {
Text(source == "Library" ? "\(model.songs.count) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary)
}
Spacer()
if showDJ,["Library","Singer Songs","Playlists"].contains(source) {
Button("Song Settings") { djSongSettings = true }
.popover(isPresented:$djSongSettings) { detailPanel.frame(width:900,height:135).padding(12) }
}
if source == "Library" {
Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered)
}
}.padding(.horizontal,22).padding(.vertical,18)
}.padding(.horizontal,22).padding(.vertical,showDJ ? 8 : 18)
sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity)
if let progress = model.waveformJob {
HStack(spacing:10) {
@@ -748,23 +755,19 @@ struct LibraryView: View {
}.padding(.horizontal,18).padding(.vertical,8)
}
Divider()
if ["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) }
Divider()
BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,8)
if !showDJ,["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) }
Divider()
footer.padding(.horizontal,18).padding(.vertical,8)
}.frame(minWidth:780)
}
}
@ViewBuilder private var workspaceLayout: some View {
#if KJMIX_DJ_CONSOLE
VSplitView {
libraryWorkspace.frame(minHeight:400)
DJConsole(model:model,system:model.dj).frame(minHeight:400,idealHeight:470)
}
#else
libraryWorkspace
#endif
if showDJ {
VSplitView {
libraryWorkspace.frame(minHeight:400)
DJConsole(model:model,system:model.dj).frame(minHeight:440,idealHeight:510)
}
} else { libraryWorkspace }
}
private var workspace: some View {
workspaceLayout
@@ -777,6 +780,7 @@ struct LibraryView: View {
.searchable(text:$search,placement:.toolbar,prompt:"Search Music")
.toolbar {
ToolbarItemGroup {
Button { showDJ.toggle() } label: { Label(showDJ ? "Hide DJ" : "Show DJ",systemImage:"waveform.path") }.help("Show or hide the DJ workspace; playback continues")
Menu {
Button("Import Files or Folder…") { importing = true }
Button("Import from SMB/NAS…") { importingServer = true }
+2 -2
View File
@@ -555,8 +555,8 @@ extension Library {
let query = try statement("SELECT songs FROM import_history WHERE path=? AND signature=?",[path,signature]);defer { sqlite3_finalize(query) }
guard sqlite3_step(query) == SQLITE_ROW,let data = text(query,0).data(using:.utf8),let ids = try? JSONDecoder().decode([Int64].self,from:data),!ids.isEmpty else { return nil }
for id in ids {
guard let track = try song(id),FileManager.default.fileExists(atPath:track.audio) else { return nil }
if let cdg = track.cdg,!FileManager.default.fileExists(atPath:cdg) { return nil }
guard let track = try song(id),MediaByteCopy.nonempty(URL(fileURLWithPath:track.audio)) else { return nil }
if let cdg = track.cdg,!MediaByteCopy.nonempty(URL(fileURLWithPath:cdg)) { return nil }
}
return ids
}
+5 -3
View File
@@ -88,7 +88,7 @@ import CoreAudio
guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") }
let running = engine.isRunning;engine.stop();var value = device
let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value)))
guard result == noErr else { throw LibraryError.message("Audio routing failed (\(result))") }
guard result == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio routing failed (\(result))") }
if running { try engine.start() }
}
struct GlobalMix: Codable, Equatable {
@@ -103,6 +103,7 @@ import CoreAudio
}
@Published private(set) var waveform = [Float]()
@Published private(set) var waveformBands = [WaveformBands]()
@Published var inputGain: Double = 0 { didSet { tone.globalGain = Float(min(12,max(-12,inputGain))) } }
@Published var lowGain: Double = 0 { didSet { updateTone() } }
@Published var midGain: Double = 0 { didSet { updateTone() } }
@Published var highGain: Double = 0 { didSet { updateTone() } }
@@ -157,9 +158,10 @@ import CoreAudio
AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled)
saveMix()
}
func load(_ song: Song, preference: SongPreference = .init()) throws {
func load(_ song: Song, preference: SongPreference = .init(), startPlaying: Bool = true) throws {
stop()
do {
guard MediaByteCopy.nonempty(URL(fileURLWithPath:song.audio)) else { throw LibraryError.message("Audio file is empty or unavailable: \(song.title). Reimport the original media to repair the library copy.") }
let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio))
let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data()
guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") }
@@ -176,7 +178,7 @@ import CoreAudio
metering = true
engine.mainMixerNode.outputVolume = Float(volume)
engine.prepare(); try engine.start()
schedule(at:0,play:true); startTimer(); tick()
schedule(at:0,play:startPlaying); startTimer(); tick()
if ["mp4","m4v","mov"].contains(URL(fileURLWithPath:song.audio).pathExtension.lowercased()) {
let item = AVPlayerItem(url:URL(fileURLWithPath:song.audio))
let output = AVPlayerItemVideoOutput(pixelBufferAttributes:[kCVPixelBufferPixelFormatTypeKey as String:kCVPixelFormatType_32BGRA])
+179 -6
View File
@@ -3,6 +3,7 @@ import SwiftUI
import AVFoundation
import AppKit
import UniformTypeIdentifiers
import CoreMIDI
@testable import KJamsClone
final class KJamsCloneTests: XCTestCase {
@@ -1342,7 +1343,7 @@ final class KJamsCloneTests: XCTestCase {
@MainActor func testActualSingerDisclosurePressAndNavigationRetainExpansion() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Alex")
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:900).preferredColorScheme(.dark))
let host = NSHostingView(rootView:LibraryView(model:model,showDJ:false).frame(width:1320,height:900).preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:1320,height:900)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host
let previous = NSApp.keyWindow
@@ -1487,7 +1488,7 @@ final class KJamsCloneTests: XCTestCase {
let a = try model.createSinger(name:"Alex",venue:venue,songs:[]),b = try model.createSinger(name:"Sam",venue:venue,songs:[])
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = try XCTUnwrap(model.library).add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();model.activeSection = "Rotation";model.selectedSinger = a
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1060,height:760).preferredColorScheme(.dark));host.frame = NSRect(x:0,y:0,width:1060,height:760)
let host = NSHostingView(rootView:LibraryView(model:model,showDJ:false).frame(width:1060,height:760).preferredColorScheme(.dark));host.frame = NSRect(x:0,y:0,width:1060,height:760)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);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() }
@@ -1501,12 +1502,12 @@ final class KJamsCloneTests: XCTestCase {
}
try await Task.sleep(nanoseconds:120_000_000)
}
try await click(485,453);try capture("songs")
try await click(990,516);try capture("added")
try await click(485,502);try capture("songs")
try await click(990,565);try capture("added")
XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song")
try await click(518,458);try capture("reordered")
try await click(518,507);try capture("reordered")
XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam")
try await click(335,338)
try await click(335,387)
XCTAssertTrue(model.player.playing,"Actual Start Rotation button must play");XCTAssertTrue(model.rotationPlaybackActive);XCTAssertEqual(model.playbackSinger,a);XCTAssertNil(model.error)
}
@@ -1626,3 +1627,175 @@ final class KJamsCloneTests: XCTestCase {
}
}
extension KJamsCloneTests {
func testDJCrossfaderAndTempoMatch() {
let left = DJMix.weights(0),right = DJMix.weights(1),centre = DJMix.weights(0.5)
XCTAssertEqual(left.0,1,accuracy:0.0001);XCTAssertEqual(left.1,0,accuracy:0.0001)
XCTAssertEqual(right.0,0,accuracy:0.0001);XCTAssertEqual(right.1,1,accuracy:0.0001)
XCTAssertEqual(centre.0*centre.0+centre.1*centre.1,1,accuracy:0.0001)
XCTAssertEqual(DJMix.weights(-20).0,1,accuracy:0.0001)
XCTAssertEqual(DJMix.weights(.nan).0,centre.0,accuracy:0.0001)
XCTAssertEqual(DJMix.syncRate(bpm:120,otherBPM:126,otherRate:1),1.05)
XCTAssertNil(DJMix.syncRate(bpm:0,otherBPM:126,otherRate:1))
XCTAssertNil(DJMix.syncRate(bpm:40,otherBPM:240,otherRate:1))
}
func testDJMIDIParserRunningStatusRealtimeSysExAndPacketSplits() {
var decoder = DJMIDIDecoder()
XCTAssertEqual(decoder.feed([0xB2,7]),[])
XCTAssertEqual(decoder.feed([0xF8,127,8,0]),[.init(status:0xB2,data1:7,data2:127),.init(status:0xB2,data1:8,data2:0)])
XCTAssertEqual(decoder.feed([0xF0,1,2,0xF8,3,0xF7,9,10]),[])
XCTAssertEqual(decoder.feed([0x92,20,127,20,0,0x82,20,0]),[.init(status:0x92,data1:20,data2:127),.init(status:0x92,data1:20,data2:0),.init(status:0x82,data1:20,data2:0)])
XCTAssertEqual(decoder.feed([0xC0,20,0xD0,1]),[])
XCTAssertEqual(DJMIDIMapping.value(127,mode:"Relative signed"),-1)
XCTAssertEqual(DJMIDIMapping.value(65,mode:"Relative offset"),1)
}
@MainActor func testDJMIDILearnPersistenceAndButtonReleaseFiltering() throws {
let suite = "KJMix.DJ.Tests.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults,connect:false)
var actions = [(String,Double)]();midi.onAction = { action,value,_ in actions.append((action,value)) }
midi.learning = "A Play";midi.receive([0x90,60,127])
XCTAssertNil(midi.learning);XCTAssertEqual(midi.mappings.count,1);XCTAssertTrue(actions.isEmpty)
midi.receive([0x90,60,0,0x80,60,0]);XCTAssertTrue(actions.isEmpty)
midi.receive([0x90,60,127]);XCTAssertEqual(actions.map{$0.0},["A Play"])
midi.learning = "Crossfader";midi.receive([0xB0,3,64]);midi.receive([0xB0,3,0])
XCTAssertEqual(actions.last?.0,"Crossfader");XCTAssertEqual(actions.last?.1,0)
let restored = DJMIDI(defaults:defaults,connect:false)
XCTAssertEqual(restored.mappings,midi.mappings)
midi.remove(midi.mappings[0].id);XCTAssertEqual(midi.mappings.count,1)
}
@MainActor func testDJPausedLoadMixerIsolationTapAndBeatLoop() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = Song(id:1,title:"DJ test",audio:fixture.path,cdg:nil,rating:0,plays:0)
let karaoke = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
karaoke.volume = 0.42;karaoke.lowGain = 3
let savedMix = try Data(contentsOf:root.appendingPathComponent("GlobalMixer.json"))
let channel = DJChannel("A");defer { channel.player.stop();karaoke.stop() }
try channel.load(song)
XCTAssertFalse(channel.player.playing);XCTAssertFalse(channel.player.audioPlaybackStarted)
XCTAssertEqual(channel.player.currentSongID,1);XCTAssertFalse(channel.bpmConfirmed)
channel.tap(at:10);channel.tap(at:10.5);channel.tap(at:11)
XCTAssertTrue(channel.bpmConfirmed);XCTAssertEqual(channel.bpm,120,accuracy:0.01)
channel.level = 0.6;channel.gain = 6;channel.crossWeight = 0
XCTAssertEqual(channel.player.volume,0,accuracy:0.0001)
channel.crossWeight = 1;XCTAssertEqual(channel.player.volume,0.48,accuracy:0.0001);XCTAssertEqual(channel.player.inputGain,6)
channel.player.lowGain = -5
XCTAssertEqual(karaoke.volume,0.42);XCTAssertEqual(karaoke.lowGain,3)
XCTAssertEqual(try Data(contentsOf:root.appendingPathComponent("GlobalMixer.json")),savedMix)
channel.loopBeats = 1;channel.player.seek(0.5);channel.beatLoop()
XCTAssertTrue(channel.player.loopEnabled);XCTAssertFalse(channel.player.playing)
XCTAssertEqual(try XCTUnwrap(channel.player.loopIn),0.5,accuracy:0.01)
XCTAssertEqual(try XCTUnwrap(channel.player.loopOut),1,accuracy:0.01)
channel.beatLoop();XCTAssertFalse(channel.player.loopEnabled)
}
@MainActor func testDJDecksPlayIndependentlyAndSyncWithoutChangingKaraoke() async throws {
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString));defer { try? FileManager.default.removeItem(at:system.root) };defer { system.a.player.stop();system.b.player.stop();system.stopPreview() }
let song = Song(id:1,title:"Two decks",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.master = 0 // Exercise real rendering silently.
system.load(song,into:"A");system.load(song,into:"B")
XCTAssertNil(system.error);XCTAssertFalse(system.a.player.playing);XCTAssertFalse(system.b.player.playing)
system.a.player.toggle();system.b.player.seek(0.5);system.b.player.toggle()
try await Task.sleep(nanoseconds:300_000_000)
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.a.player.stopDeck();XCTAssertTrue(system.b.player.playing)
system.startPreview(system.b);XCTAssertNotNil(system.error);XCTAssertNil(system.previewDeck);XCTAssertFalse(system.preview.playing)
}
}
extension KJamsCloneTests {
@MainActor func testDJWorkstationNativeLayoutAndTransport() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
defer { model.player.stop();model.backgroundDeck.unload();model.dj.a.player.stop();model.dj.b.player.stop() }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let id = try XCTUnwrap(model.library).add(title:"Midnight City · demo audio",audio:fixture.path,cdg:nil)
try model.refresh()
_ = try model.dj.library?.add(title:"DJ demo audio",audio:fixture.path,cdg:nil);try model.dj.refresh()
model.dj.load(try XCTUnwrap(model.dj.songs.first),into:"A");model.dj.load(try XCTUnwrap(model.dj.songs.first),into:"B");model.dj.master = 0
model.studio.automaticBackground = false
try model.backgroundDeck.load(fixture,title:"Between singers · warm-up")
model.dj.a.bpmConfirmed = true;model.dj.b.bpmConfirmed = true
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:1100).preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:1320,height:1100)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.appearance = NSAppearance(named:.darkAqua);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:350_000_000);host.layoutSubtreeIfNeeded()
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-dj-native.png")) }
XCTAssertEqual(model.dj.a.player.currentSongID,id);XCTAssertEqual(model.dj.b.player.currentSongID,id)
XCTAssertFalse(model.dj.a.player.playing);XCTAssertNil(model.player.currentSongID)
}
}
extension KJamsCloneTests {
@MainActor func testDJReceivesActualCoreMIDIVirtualSourcePackets() async throws {
var client = MIDIClientRef(0),source = MIDIEndpointRef(0)
XCTAssertEqual(MIDIClientCreateWithBlock("KJMix MIDI test" as CFString,&client,nil),noErr)
defer { if source != 0 { MIDIEndpointDispose(source) };MIDIClientDispose(client) }
XCTAssertEqual(MIDISourceCreate(client,"KJMix virtual test controller" as CFString,&source),noErr)
let suite = "KJMix.VirtualMIDI.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
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)
var actions = [String]();midi.onAction = { action,_,_ in actions.append(action) }
func send(_ packets: [[UInt8]]) throws {
var list = MIDIPacketList()
try withUnsafeMutablePointer(to:&list) { pointer in
var packet = MIDIPacketListInit(pointer)
for bytes in packets {
packet = try bytes.withUnsafeBufferPointer { data in try XCTUnwrap(MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,data.count,data.baseAddress!)) }
}
XCTAssertEqual(MIDIReceived(source,pointer),noErr)
}
}
midi.learning = "A Play";try send([[0x90,40,127]])
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(midi.mappings.first?.action,"A Play");XCTAssertTrue(actions.isEmpty)
try send([[0x90,40,0],[0x90,40,127],[0x80,40,0]])
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(actions,["A Play"])
midi.select(0)
}
}
extension KJamsCloneTests {
@MainActor func testDJLibraryRecursiveImportPlaylistsAndPersistenceAreSeparateFromKaraoke() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let folder = root.appendingPathComponent("Input/Artist/Album")
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:folder.appendingPathComponent("DJ Song.mp3"))
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:folder.appendingPathComponent("DJ Song.cdg"))
let karaoke = try XCTUnwrap(model.library),dj = try XCTUnwrap(model.dj.library)
_ = try karaoke.add(title:"Karaoke only",audio:fixtures.appendingPathComponent("TestTrack.wav").path,cdg:nil)
try model.refresh();XCTAssertTrue(model.dj.songs.isEmpty)
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertFalse(model.dj.importing);XCTAssertNil(model.dj.error)
XCTAssertEqual(model.dj.songs.count,1);XCTAssertEqual(try karaoke.songs().map(\.title),["Karaoke only"])
let track = try XCTUnwrap(model.dj.songs.first)
XCTAssertTrue(track.audio.hasPrefix(root.appendingPathComponent("DJ/Media").path));XCTAssertNil(track.cdg)
model.dj.createPlaylist("DJ set");let playlist = try XCTUnwrap(model.dj.playlists.first)
model.dj.addToPlaylist(playlist.id,song:track.id)
XCTAssertTrue(try karaoke.entities("playlists").isEmpty)
XCTAssertEqual(try dj.playlistSongIDs(playlist.id),[track.id])
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertEqual(model.dj.songs.count,1)
let reopened = DJSystem(storageURL:model.dj.root)
XCTAssertEqual(reopened.songs.map(\.id),[track.id]);XCTAssertEqual(reopened.playlists.map(\.name),["DJ set"])
XCTAssertNotEqual(model.dragReference(track.id).library,model.dj.dragReference(track.id).library)
}
}
+4
View File
@@ -2,6 +2,10 @@
Open **KJMix Pro.xcodeproj**, choose the shared **KJMix Pro** scheme and **My Mac**, then Run. The project contains a real project.pbxproj, SwiftUI macOS app, SQLite library, XCTest target, complete icon assets and build/test/release scripts. No project generator or third-party package is required.
## DJ workstation branch
`feature/dj_workstation` adds the split karaoke/DJ workspace, cyan/amber two-deck controls, rotating jog platters, decoded waveforms, EQ/filter/gain, transport/cues/loops, tempo matching, crossfader, independent DJ library/imports/playlists, device routing and MIDI learn. See [DJ workstation usage and limits](docs/DJ_WORKSTATION.md), especially independent headphone preview, manual BPM sync and unverified controller hardware.
## Feature status
| Feature | This build |
+50
View File
@@ -0,0 +1,50 @@
# DJ workstation — feature/dj_workstation
The main window now opens with the original karaoke workspace above a resizable DJ workspace. The toolbar’s Show/Hide DJ button returns the entire window to karaoke without stopping any deck. Existing singer, venue, rotation, video, background music, library and recursive directory import features remain available. Background music is a compact single-row strip above karaoke transport. In split view, song settings open from the Song Settings button; karaoke-only view keeps the full settings strip. DJ controls have mirrored tone panels, consistent padding and a separate browser area; the DJ workspace scrolls vertically when the split is too short to show everything.
## 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.
- 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, bipolar filter, gain and level are beside each platter. 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.
- DJ master, channel levels and equal-power crossfader affect only DJ playback. The karaoke master and saved singer preferences are independent. The two 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.
## Library and playlists
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.
## Audio routing
The routing sheet enumerates CoreAudio output devices. Both DJ decks can route to one selected master device; karaoke can independently route to another device. Defaults use the system output. Selections are session-only. Background music retains its existing output.
Headphone preview is real, independent playback on another selected output device. It starts at the selected deck’s position with that deck’s current tempo/EQ/filter, before its gain, level and crossfader. Later deck changes are not followed; restart preview to refresh. One deck can be previewed at a time. Preview is disabled until an output is explicitly selected, and rejects the current master device to avoid sending cue audio publicly.
**Not implemented:** master/cue channel-pair selection on a single multichannel device, phase-locked PFL, aggregate-device creation, input/microphone routing, automatic device failover, and calibrated round-trip latency. Devices use their default stereo output pair. A Numark’s master and headphone sockets on the same CoreAudio device therefore cannot yet be independently addressed. Hardware routing needs verification on the intended interface.
## 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.
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
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.
**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.
## 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.