import SwiftUI import AVFoundation import CoreAudio import CoreMIDI import UniformTypeIdentifiers import Combine /// Equal-power crossfade, independent of the karaoke master and singer preferences. enum DJMix { static func weights(_ position: Double,curve: String = "Equal power") -> (Double, Double) { let x = position.isFinite ? min(1,max(0,position)) : 0.5 if curve == "Linear" { return (1-x,x) } 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 } } struct DJPreferences: Codable, Equatable { var autoPlayOnLoad = false var resetEQOnLoad = false var protectPlayingDeck = true var tempoRange: Double = 16 var invertTempo = true var crossfaderCurve = "Equal power" var headroomDB: Double = 0 var spectralWaveforms = true var waveformSeconds: Double = 8 var showTimeGrid = true var platterMarker = true var dimInactiveDeck = false var libraryFont: Double = 11 var libraryRowHeight: Double = 28 var autoPlayHotCue = false func tempo(at position: Double) -> Double { let x = min(1,max(0,position));return 1+((invertTempo ? 1-x : x)*2-1)*tempoRange/100 } func position(for tempo: Double) -> Double { let x = min(1,max(0,(tempo-1)/(2*tempoRange/100)+0.5));return invertTempo ? 1-x : x } var sanitized: Self { var p = self;p.tempoRange = [8.0,16,50].contains(tempoRange) ? tempoRange : 16 p.headroomDB = [0.0,3,6,9].contains(headroomDB) ? headroomDB : 0 p.waveformSeconds = [4.0,8,16,32].contains(waveformSeconds) ? waveformSeconds : 8 p.libraryFont = libraryFont.isFinite ? min(16,max(10,libraryFont)) : 11 p.libraryRowHeight = libraryRowHeight.isFinite ? min(44,max(24,libraryRowHeight)) : 28 if !["Equal power","Linear"].contains(crossfaderCurve) { p.crossfaderCurve = "Equal power" };return p } } @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 bpmEstimated = false @Published var analysingBPM = false @Published var artwork: NSImage? private var infoTask: Task? 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 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;bpmEstimated = false;bpm = 120;taps = [];loadTrackInfo(song) } func unload() { infoTask?.cancel();analysisTask?.cancel();player.stop();song = nil;bpmConfirmed = false;artwork = nil;analysingBPM = false } private func loadTrackInfo(_ song: Song) { infoTask?.cancel();analysisTask?.cancel();artwork = nil;analysingBPM = true infoTask = Task { @MainActor [weak self] in let image = await DJTrackInfo.artwork(song) guard !Task.isCancelled,let self,self.song?.id == song.id else { return } self.artwork = image let task = Task.detached(priority:.utility) { try? DJBPM.analyse(URL(fileURLWithPath:song.audio),cancelled:{Task.isCancelled}) };self.analysisTask = task let bpm = await task.value guard !Task.isCancelled,self.song?.id == song.id else { return } self.analysingBPM = false if !self.bpmConfirmed,let bpm { self.bpm = bpm;self.bpmEstimated = true;self.bpmConfirmed = true } } } deinit { infoTask?.cancel();analysisTask?.cancel() } 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;bpmEstimated = false } } 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) player.defineLoop(start:start,end:end) } } 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.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?;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") } } static func defaultOutput() -> AudioDeviceID? { var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDefaultOutputDevice,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain) var id = AudioDeviceID(0),size = UInt32(MemoryLayout.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 private(set) var syncActive = false private var syncOriginal: (Double,Double)? private var syncObservers = Set() private var updatingSync = false @Published var pendingReplacement: DJReplacement? @Published var cueEditor: String? @Published var cueDraft: Double = 0 @Published var cueEditing = false @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 settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } } @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? 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() Publishers.CombineLatest4(a.$bpm,b.$bpm,a.player.$tempo,b.player.$tempo).dropFirst().sink { [weak self] _ in Task { @MainActor [weak self] in self?.maintainSync() } }.store(in:&syncObservers) Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).sink { [weak self] _ in Task { @MainActor [weak self] in self?.updateControllerFeedback() } }.store(in:&syncObservers) perform { try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh() if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized } applyMix() } } private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } } 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],loadInto: String? = nil,overridePlaying: Bool = false) { guard !importing,!urls.isEmpty else { return } importing = true;importProgress = .init() let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),replacedSong = loadInto.flatMap{channel($0).song?.id} importTask = Task.detached(priority:.utility) { [weak self] in let access = urls.filter{$0.startAccessingSecurityScopedResource()} defer { for url in access { url.stopAccessingSecurityScopedResource() } } var latest = ImportProgress() var importedIDs = [Int64]() do { let db = try Library(url:database) importedIDs = 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,loadedIDs = importedIDs 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 } else if let loadInto,let song = self.songs.first(where:{loadedIDs.contains($0.id)}) { self.load(song,into:loadInto,overridePlaying:overridePlaying && self.channel(loadInto).song?.id == replacedSong) } } } } 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,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = weights.0;b.crossWeight = weights.1 } func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b } func load(_ song: Song,into letter: String,overridePlaying: Bool = false) { perform { let deck = channel(letter) if settings.protectPlayingDeck,deck.player.playing,!overridePlaying { pendingReplacement = .init(deck:letter,song:song,urls:[]);return } if syncActive { disableSync() } if previewDeck == letter { stopPreview() } try deck.load(song) if !masterUID.isEmpty { do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error } } if settings.resetEQOnLoad { deck.player.resetTone() } if settings.autoPlayOnLoad { deck.player.toggle() } 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 previewLibrary(_ song: Song) { perform { guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") } let output = try device(cueUID) guard output != (try device(masterUID)) else { throw LibraryError.message("Library preview needs a separate headphone device.") } stopPreview();var audio = song;audio.cdg = nil try preview.load(audio,startPlaying:false);try preview.routeOutput(output);preview.toggle();previewDeck = "Library" } } func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() } func startPreview(_ deck: DJChannel) { if previewDeck == deck.letter { stopPreview();return } perform { 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;updateControllerFeedback() status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position" } } func sync(_ deck: DJChannel) { if syncActive { disableSync();return } guard a.player.canJog,b.player.canJog else { status = "Load both decks before enabling Sync.";return } guard a.bpmConfirmed,b.bpmConfirmed else { status = "Waiting for BPM analysis. Set or tap BPM on both decks if a tempo cannot be detected.";return } let target = a.bpm*a.player.tempo,rate = target/b.bpm guard rate.isFinite,(0.5...2).contains(rate) else { status = "These tracks require a tempo outside the supported 50–200% range.";return } syncOriginal = (a.player.tempo,b.player.tempo) syncActive = true;updatingSync = true;b.player.tempo = rate;updatingSync = false status = String(format:"SYNC ON · Both decks %.1f BPM · A is master",target) } func disableSync() { guard syncActive else { return };syncActive = false if let original = syncOriginal { a.player.tempo = original.0;b.player.tempo = original.1 } syncOriginal = nil;status = "SYNC OFF · Both decks restored to their pre-sync tempos" } private func maintainSync() { guard syncActive,!updatingSync,a.bpmConfirmed,b.bpmConfirmed else { return } let rate = a.bpm*a.player.tempo/b.bpm guard rate.isFinite,(0.5...2).contains(rate) else { disableSync();return } if abs(b.player.tempo-rate) > 0.00001 { updatingSync = true;b.player.tempo = rate;updatingSync = false } } func toggleLoop(_ deck: DJChannel) { guard deck.player.canJog else { status = "Load a track first.";return } guard deck.player.loopEnabled || deck.player.time < deck.player.duration-0.1 else { status = "Move the playhead before the end of the track to set a loop.";return } guard deck.player.loopEnabled || deck.bpmConfirmed else { status = "Waiting for BPM analysis. Enter or tap the track BPM, or define a loop with IN and OUT.";return } deck.beatLoop();status = deck.player.loopEnabled ? "Deck \(deck.letter) · \(deck.loopBeats)-beat loop active" : "Deck \(deck.letter) · Loop off" } func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true } func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false } private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck) } func hotCue(_ index: Int,deck: DJChannel) { let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil deck.player.hotCue(index) if existing,settings.autoPlayHotCue,!deck.player.playing { deck.player.toggle() } } func action(_ name: String,value: Double = 1,relative: Bool = false,selected: Song? = nil) { if name == "Controller feedback" { updateControllerFeedback();return } if name == "Crossfader" { crossfader = min(1,max(0,value));return } if name == "Master" { master = min(1,max(0,value));return } if name == "Cue volume" { cueLevel = min(1,max(0,value));return } let parts = name.split(separator:" ",maxSplits:1).map(String.init) guard parts.count == 2 else { return };let d = channel(parts[0]),p = d.player switch parts[1] { case "Play": p.toggle() case "Stop": p.stopDeck() case "Cue": p.returnToCue() case "Set cue": p.setCue() case "Cue button": if p.playing { p.returnToCue() } else { p.setCue() } case "Cue play": p.returnToCue();p.toggle() case "Loop off": p.clearLoop() case "Loop in": p.markLoopIn() case "Loop out": p.markLoopOut() case "Loop double": d.loopBeats = min(32,d.loopBeats*2) case "Loop half": d.loopBeats = max(1,d.loopBeats/2) 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 } case "Loop 1","Loop 2","Loop 4","Loop 8","Loop 16","Loop 32": d.loopBeats = Int(parts[1].split(separator:" ").last!) ?? 8;toggleLoop(d) case "Clear cue 1","Clear cue 2","Clear cue 3","Clear cue 4": p.hotCue((Int(parts[1].suffix(1)) ?? 1)-1,clear:true) case "Sync": sync(d) case "Loop": toggleLoop(d) case "Headphones": startPreview(d) 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": if syncActive,d.letter == "B" { a.player.tempo = b.bpm*settings.tempo(at:value)/a.bpm;maintainSync() } else { p.tempo = settings.tempo(at:value) } case "Jog ticks": p.jog(value*2/242) case "Jog": p.jog(relative ? value*0.05 : (value-0.5)*0.2) case "Hot cue 1": hotCue(0,deck:d) case "Hot cue 2": hotCue(1,deck:d) case "Hot cue 3": hotCue(2,deck:d) case "Hot cue 4": hotCue(3,deck:d) 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,0xE0].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 private(set) var profiles = [String:String]() @Published var xmlCatalog = DJXMLProfile.bundled @Published private(set) var xmlAssignments = [String:DJXMLProfile]() @Published var autoDetect = true { didSet { defaults.set(autoDetect,forKey:"DJ.MIDIAutoDetect.v1");if autoDetect { refreshSources() } } } private var pickupValues = [String:Double](),pickedUp = Set() var currentValue: ((String) -> Double?)? 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) @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 autoDetect = defaults.object(forKey:"DJ.MIDIAutoDetect.v1") as? Bool ?? true if let data = defaults.data(forKey:"DJ.XMLAssignments.v1"),let saved = try? JSONDecoder().decode([String:DJXMLProfile].self,from:data) { xmlAssignments = saved } if let data = defaults.data(forKey:"DJ.XMLCatalog.v1"),let saved = try? JSONDecoder().decode([DJXMLProfile].self,from:data) { mergeXML(saved) } profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:] if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved } guard connect else { return } let result = MIDIClientCreateWithBlock("KJMix DJ" as CFString,&client) { [weak self] _ in Task { @MainActor in self?.refreshSources() } } guard result == noErr else { status = "MIDI unavailable (\(result))";return } MIDIOutputPortCreate(client,"DJ feedback" as CFString,&outputPort) let input = MIDIInputPortCreateWithBlock(client,"DJ controls" as CFString,&port) { [weak self] list,_ in var bytes = [UInt8]() // Walk the original variable-length packet list, never a copied 256-byte tuple. var packet = UnsafeRawPointer(list).advanced(by:MemoryLayout.offset(of: \.packet)!).assumingMemoryBound(to:MIDIPacket.self) for _ in 0...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..? 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}) { disconnect();status = "Controller disconnected · waiting to reconnect" } if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber, sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) } if connected == 0,autoDetect,defaults.object(forKey:"DJ.MIDISource.v1") == nil { let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil || DJXMLProfile.normalized(source.name) == "numark mixtrack go" } if matches.count == 1,let match = matches.first { select(match.id) } else if matches.count > 1 { status = "Multiple supported controllers detected · choose a MIDI input" } } } private func disconnect() { if connected != 0 { MIDIPortDisconnectSource(port,connected) };connected = 0;sourceID = 0;decoder = .init();learning = nil;outputDestination = 0;pickupValues = [:];pickedUp = [] } func select(_ id: Int32) { disconnect() if id == 0 { defaults.removeObject(forKey:"DJ.MIDISource.v1");autoDetect = false;return } guard let source = sources.first(where:{$0.id == id}) else { return } let result = MIDIPortConnectSource(port,source.endpoint,nil) if result == noErr { connected = source.endpoint;sourceID = id;defaults.set(Int(id),forKey:"DJ.MIDISource.v1");status = "Connected: \(source.name)";mixtrack = .init();selectFeedbackOutput(source.name) if autoDetect,profiles[String(id)] == nil { if let profile = DJXMLProfile.match(source.name,in:xmlCatalog) { applyXML(profile) } else if DJXMLProfile.normalized(source.name) == "numark mixtrack go" { installMixtrackGo() } } } else { status = "Could not connect MIDI source (\(result))" } } func receive(_ bytes: [UInt8]) { for message in decoder.feed(bytes) { let type = message.status & 0xF0 if let profile = activeXML,learning == nil { if type == 0x80 || (type == 0x90 && message.data2 == 0) { continue } for binding in profile.bindings where binding.matches(message) { let value = binding.value(message) if binding.pickup,binding.mode == "Absolute",let target = currentValue?(binding.action),!pickedUp.contains(binding.key) { let previous = pickupValues[binding.key];pickupValues[binding.key] = value guard abs(value-target) < 0.025 || previous.map({ ($0-target)*(value-target) <= 0 }) == true else { continue } pickedUp.insert(binding.key) } if binding.mode != "Absolute",value == 0 { continue } onAction?(binding.action,value,binding.mode != "Absolute") } continue } 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 } if profiles[String(sourceID)] == "Mixtrack Go",let events = mixtrack.consume(message) { for event in events { if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) } else { onAction?(event.action,event.value,event.relative) } } onAction?("Controller feedback",0,false) continue } guard let map = mappings.first(where:{$0.source == sourceID && $0.status == message.status && $0.number == message.data1}) else { continue } let continuous = ["Gain","High","Mid","Low","Filter","Level","Tempo","Jog"].contains(map.action.split(separator:" ",maxSplits:1).last.map(String.init) ?? "") || ["Crossfader","Master","Cue volume","Browse"].contains(map.action) if !continuous && message.data2 == 0 { continue } onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute") } } var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" } func installMixtrackGo() { let preset = MixtrackGoProtocol.mappings(source:sourceID) let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset profiles[String(sourceID)] = "Mixtrack Go";defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1");mixtrack = .init();save() sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]) status = "Mixtrack Go preset loaded · 14-bit tempo, relative jog, hot-cue/loop pad modes. Hardware testing required." onAction?("Controller feedback",0,false) } func useCustomProfile() { profiles[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 xmlCatalog.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } func addXMLProfiles(_ imported: [DJXMLProfile]) { mergeXML(imported) if let data = try? JSONEncoder().encode(xmlCatalog) { defaults.set(data,forKey:"DJ.XMLCatalog.v1") } status = "Imported \(imported.count) XML profiles · select one to review and apply" if autoDetect { refreshSources() } } func applyXML(_ profile: DJXMLProfile) { guard !profile.bindings.isEmpty else { status = "This profile has no supported standalone MIDI controls · see compatibility report";return } xmlAssignments[String(sourceID)] = profile;profiles[String(sourceID)] = "XML:"+profile.id defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1") if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") } pickupValues = [:];pickedUp = [];learning = nil status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report" } private func selectFeedbackOutput(_ name: String) { outputDestination = 0 for i in 0..? MIDIObjectGetStringProperty(endpoint,kMIDIPropertyDisplayName,&result) let candidate = (result?.takeRetainedValue() as String? ?? "").lowercased() if candidate == name.lowercased() || (candidate.contains("mixtrack") && candidate.contains("go")) { outputDestination = endpoint;break } } } func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) { guard mixtrackEnabled else { return } sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue)) } private func sendBytes(_ bytes: [UInt8]) { guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return } var list = MIDIPacketList() withUnsafeMutablePointer(to:&list) { pointer in let packet = MIDIPacketListInit(pointer) let added = bytes.withUnsafeBufferPointer { MIDIPacketListAdd(pointer,MemoryLayout.size,packet,0,$0.count,$0.baseAddress!) } if added != nil { MIDISend(outputPort,outputDestination,pointer) } } } func remove(_ id: String) { mappings.removeAll{$0.id == id};save() } private func save() { if let data = try? JSONEncoder().encode(mappings) { defaults.set(data,forKey:"DJ.MIDIMappings.v1") } } deinit { if port != 0 { MIDIPortDispose(port) };if outputPort != 0 { MIDIPortDispose(outputPort) };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 var expanded = false @State private var settingsModal = false @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 DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}) browser.frame(height:expanded ? 300 : 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) .alert("Replace playing track?",isPresented:Binding(get:{system.pendingReplacement != nil},set:{if !$0 { system.pendingReplacement = nil }}),presenting:system.pendingReplacement) { request in Button("Cancel",role:.cancel) { system.pendingReplacement = nil } Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) } } message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } .sheet(isPresented:$settingsModal) { StudioSettingsDialog(model:model,scope:"DJ") } .sheet(isPresented:$routing) { StudioSettingsDialog(model:model,scope:"DJ",category:"Devices") } .sheet(isPresented:$mapping) { StudioSettingsDialog(model:model,scope:"DJ",category:"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 { settingsModal = true } label: { Label("DJ Settings",systemImage:"gearshape") } Button { routing = true } label: { Label("Audio Routing",systemImage:"hifispeaker.fill") } Button { mapping = true } label: { Label("MIDI",systemImage:"pianokeys") } }.controlSize(.small) } 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: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("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) .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() { system.midi.currentValue = { [weak system] action in guard let system else { return nil } if action == "Crossfader" { return system.crossfader } if action == "Master" { return system.master } if action == "Cue volume" { return system.cueLevel } let parts = action.split(separator:" ",maxSplits:1).map(String.init) guard parts.count == 2 else { return nil };let d = system.channel(parts[0]),p = d.player switch parts[1] { case "Tempo XML": return (p.tempo-1)/(2*system.settings.tempoRange/100)+0.5 case "High": return (p.highGain+12)/24 case "Mid": return (p.midGain+12)/24 case "Low": return (p.lowGain+12)/24 case "Filter": return (p.filter+1)/2 case "Gain": return (d.gain+12)/24 case "Level": return d.level default: return nil } } system.midi.onAction = { [weak system] name,value,relative in guard let system else { return } if name == "Browse" { guard value != 0 else { return } let rows = songs;guard !rows.isEmpty else { return } let current = rows.firstIndex(where:{$0.id == selected}) ?? (value > 0 ? -1 : rows.count) let index = min(rows.count-1,max(0,current+(value >= 0 ? 1 : -1))) selected = rows[index].id;page = index/50 } else if name == "Preview selected",let selectedSong { system.previewLibrary(selectedSong) } else { system.action(name,value:value,relative:relative,selected:selectedSong) } } } private func importMedia() { let panel = NSOpenPanel();panel.title = "Import DJ Music · Files or Folders";panel.canChooseFiles = true;panel.canChooseDirectories = true;panel.allowsMultipleSelection = true if panel.runModal() == .OK { system.importFiles(panel.urls) } } } private struct DJDeckSurfaces { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem private var color: Color { DJStyle.color(deck.letter) } var tempoStrip: some View { VStack(spacing:5) { Button("SYNC") { system.sync(deck) }.font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(color,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Toggle both decks to deck A’s BPM; click again to restore their original tempos").accessibilityIdentifier("dj.\(deck.letter).sync") Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : "—").font(.system(size:11,weight:.semibold).monospacedDigit()) Button(String(format:"%+.1f%%",(player.tempo-1)*100)) { player.tempo = 1 }.font(.system(size:9).monospacedDigit()).buttonStyle(.plain).foregroundStyle(.secondary).help("Reset tempo") DJHardwareFader(value:Binding(get:{system.settings.position(for:player.tempo)},set:{player.tempo = system.settings.tempo(at:$0)}),label:"Deck \(deck.letter) tempo",vertical:true).frame(width:52,height:94) HStack(spacing:16) { Button("−") { player.tempo = max(1-system.settings.tempoRange/100,player.tempo-0.001) };Button("+") { player.tempo = min(1+system.settings.tempoRange/100,player.tempo+0.001) } }.buttonStyle(.plain).font(.system(size:15,weight:.medium)) }.frame(width:62).disabled(!player.canJog) } var tonePanel: some View { HStack(alignment:.top,spacing:10) { VStack(spacing:8) { 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:12) { DJKnob(label:"FILTER",value:$player.filter,range:-1...1,reset:0,color:color,format:{String(format:"%+.0f%%",$0*100)}) VStack(spacing:5) { ForEach(0..<8) { i in RoundedRectangle(cornerRadius:1).fill(Double(max(player.leftLevel,player.rightLevel))*8 > Double(7-i) ? color : Color.white.opacity(0.07)).frame(width:8,height:5) } }.accessibilityLabel("Deck output meter") } }.frame(width:88) } } struct DJKnob: View { let label: String @Binding var value: Double let range: ClosedRange 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()) .simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil}) Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary) }.frame(minWidth:36).contentShape(Rectangle()) .highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil }) .accessibilityAction(named:Text("Reset to default")) { value = reset;initial = nil } .help("\(label): \(format(value)). Drag vertically; double-click to reset.") .accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value)) .accessibilityAdjustableAction { direction in let step = (range.upperBound-range.lowerBound)/100;value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? step : -step))) } } } private struct DJPlatter: View { @ObservedObject var player: Player let letter: String let color: Color var markerEnabled = true @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) if markerEnabled { Rectangle().fill(color).frame(width:5,height:26).offset(y:-geometry.size.height*0.37) Circle().fill(.white).frame(width:5,height:5).offset(y:-geometry.size.height*0.45) } }.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 bpm: Double? 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.. Coordinator { Coordinator(value:$value) } func makeNSView(context: Context) -> NSSlider { let slider = NSSlider(value:value,minValue:0,maxValue:1,target:context.coordinator,action:#selector(Coordinator.changed(_:))) slider.cell = DJHardwareFaderCell() slider.target = context.coordinator;slider.action = #selector(Coordinator.changed(_:)) slider.frame = NSRect(x:0,y:0,width:vertical ? 52 : 240,height:vertical ? 94 : 36) slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical slider.minValue = 0;slider.maxValue = 1;slider.doubleValue = value slider.isContinuous = true;slider.numberOfTickMarks = 9;slider.allowsTickMarkValuesOnly = false slider.setAccessibilityLabel(label) slider.setContentHuggingPriority(.defaultLow,for:.horizontal) return slider } func updateNSView(_ slider: NSSlider,context: Context) { context.coordinator.value = $value;slider.isEnabled = context.environment.isEnabled;slider.isVertical = vertical;slider.doubleValue = min(1,max(0,value));slider.setAccessibilityLabel(label) } func sizeThatFits(_ proposal: ProposedViewSize,nsView: NSSlider,context: Context) -> CGSize? { CGSize(width:proposal.width ?? (vertical ? 52 : 240),height:proposal.height ?? (vertical ? 94 : 36)) } final class Coordinator: NSObject { var value: Binding init(value: Binding) { self.value = value } @objc func changed(_ sender: NSSlider) { value.wrappedValue = sender.doubleValue } } } private final class DJHardwareFaderCell: NSSliderCell { override func drawBar(inside rect: NSRect,flipped: Bool) { let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0) NSColor(calibratedWhite:0.36,alpha:1).setStroke() for i in 0...8 { let mark = NSBezierPath() if vertical { let y = rect.minY+rect.height*Double(i)/8;mark.move(to:NSPoint(x:rect.midX-21,y:y));mark.line(to:NSPoint(x:rect.midX+21,y:y)) } else { let x = rect.minX+rect.width*Double(i)/8;mark.move(to:NSPoint(x:x,y:rect.midY-13));mark.line(to:NSPoint(x:x,y:rect.midY+13)) } mark.lineWidth = 1;mark.stroke() } let groove = vertical ? NSRect(x:rect.midX-2,y:rect.minY,width:4,height:rect.height) : NSRect(x:rect.minX,y:rect.midY-2,width:rect.width,height:4) NSColor(calibratedWhite:0.025,alpha:1).setFill();NSBezierPath(roundedRect:groove,xRadius:2,yRadius:2).fill() } override func drawTickMarks() {} override func drawKnob(_ rect: NSRect) { let vertical = (controlView?.bounds.height ?? 0) > (controlView?.bounds.width ?? 0) let cap = vertical ? NSRect(x:rect.midX-21,y:rect.midY-6,width:42,height:12) : NSRect(x:rect.midX-6,y:rect.midY-14,width:12,height:28) let path = NSBezierPath(roundedRect:cap,xRadius:4,yRadius:4) NSGradient(starting:NSColor(calibratedWhite:0.60,alpha:1),ending:NSColor(calibratedWhite:0.30,alpha:1))?.draw(in:path,angle:90) NSColor(calibratedWhite:0.75,alpha:0.6).setStroke() let marker = NSBezierPath();if vertical { marker.move(to:NSPoint(x:cap.minX+6,y:cap.midY));marker.line(to:NSPoint(x:cap.maxX-6,y:cap.midY)) } else { marker.move(to:NSPoint(x:cap.midX,y:cap.minY+5));marker.line(to:NSPoint(x:cap.midX,y:cap.maxY-5)) };marker.lineWidth = 1;marker.stroke() } } struct DJContinuousConsole: View { @ObservedObject var system: DJSystem let loadA: () -> Void let loadB: () -> Void var body: some View { VStack(spacing:0) { HStack(spacing:0) { DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) Divider() DJTrackHeader(deck:system.b,player:system.b.player,system:system,load:loadB) }.frame(height:92).background(Color.black.opacity(0.4)) Divider() HStack(spacing:0) { DJConsoleWing(deck:system.a,player:system.a.player,system:system) Divider() VStack(spacing:1) { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) }.frame(maxWidth:.infinity).background(.black) Divider() DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(height:198).background(Color(white:0.12)) Divider() HStack(spacing:12) { DJConsoleTransport(deck:system.a,player:system.a.player,system:system) VStack(spacing:0) { DJHardwareFader(value:$system.crossfader,label:"DJ crossfader").frame(height:36) HStack { Text("A").foregroundStyle(DJStyle.a);Spacer();Button("Centre") { system.crossfader = 0.5 }.buttonStyle(.plain);Spacer();Text("B").foregroundStyle(DJStyle.b) }.font(.system(size:9)) }.frame(width:195) DJConsoleTransport(deck:system.b,player:system.b.player,system:system) }.padding(.horizontal,12).padding(.vertical,10).background(Color(white:0.14)) }.overlay(Rectangle().stroke(.white.opacity(0.09))) } } private struct DJTrackHeader: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem let load: () -> Void private var color: Color { DJStyle.color(deck.letter) } var body: some View { HStack(spacing:10) { if deck.letter == "A" { artwork } VStack(alignment:.leading,spacing:5) { HStack { VStack(alignment:.leading,spacing:2) { Text(deck.song?.title ?? "Load deck \(deck.letter)").font(.system(size:15,weight:.medium)).lineLimit(1) HStack(spacing:6) { Text(deck.song?.artist.isEmpty == false ? deck.song!.artist : "Local DJ library").foregroundStyle(.secondary).lineLimit(1) TextField("BPM",value:Binding(get:{deck.bpm},set:{deck.bpm = min(240,max(40,$0));deck.bpmConfirmed = true;deck.bpmEstimated = false}),format:.number.precision(.fractionLength(1))).frame(width:44).textFieldStyle(.roundedBorder).controlSize(.mini).accessibilityLabel("Deck \(deck.letter) source BPM") Text(deck.analysingBPM ? "…" : deck.bpmEstimated ? "BPM~" : deck.bpmConfirmed ? "BPM" : "BPM?").foregroundStyle(.secondary) Button("Tap") { deck.tap() }.controlSize(.mini) }.font(.system(size:10)).disabled(!player.canJog) } Spacer(minLength:4) Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:17).monospacedDigit()) Button("Load",action:load).controlSize(.mini) } DJWaveform(player:player,color:color,bpm:deck.bpmConfirmed ? deck.bpm : nil).frame(height:22) } if deck.letter == "B" { artwork } }.padding(10).frame(maxWidth:.infinity) .onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } } private var artwork: some View { ZStack { RoundedRectangle(cornerRadius:4).fill(color.opacity(0.12)) if let image = deck.artwork { Image(nsImage:image).resizable().scaledToFill() } else { Image(systemName:"music.note").font(.system(size:26)).foregroundStyle(color) } }.frame(width:62,height:62).clipped().clipShape(RoundedRectangle(cornerRadius:4)) } } private struct DJConsoleWing: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem var body: some View { let face = DJDeckSurfaces(deck:deck,player:player,system:system) HStack(spacing:10) { if deck.letter == "B" { face.tonePanel } if deck.letter == "A" { face.tempoStrip } ZStack { DJPlatter(player:player,letter:deck.letter,color:DJStyle.color(deck.letter),markerEnabled:system.settings.platterMarker).frame(width:134,height:134) VStack(spacing:3) { Text(String(format:"%+.1f%%",(player.tempo-1)*100)).font(.system(size:9)).foregroundStyle(.secondary) Text(deck.bpmConfirmed ? String(format:"%.1f",deck.bpm*player.tempo) : deck.letter).font(.system(size:21,weight:.semibold).monospacedDigit()).foregroundStyle(DJStyle.color(deck.letter)) Divider().frame(width:62) Text(DJStyle.stamp(player.time)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary) Text("−"+DJStyle.stamp(player.duration-player.time)).font(.system(size:11).monospacedDigit()) }.frame(width:95,height:95).background(Color(white:0.095),in:Circle()).allowsHitTesting(false) } if deck.letter == "A" { face.tonePanel } if deck.letter == "B" { face.tempoStrip } }.padding(.horizontal,9).frame(width:322).opacity(system.settings.dimInactiveDeck && !player.playing ? 0.55 : 1) .onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } } } private struct DJConsoleTransport: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player @ObservedObject var system: DJSystem private var playButton: some View { Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill").font(.system(size:17)).frame(width:36,height:22) }.foregroundStyle(player.playing ? Color.green : Color.white).accessibilityLabel("Deck \(deck.letter) play pause").accessibilityIdentifier("dj.\(deck.letter).play") } private var cueButton: some View { Button("CUE") { player.returnToCue() }.font(.system(size:11,weight:.semibold)).frame(height:22).contextMenu { Button("Set cue here") { player.setCue() } } } private var loopButtons: some View { HStack(spacing:0) { Button("‹") { deck.loopBeats = max(1,deck.loopBeats/2) } Button("\(deck.loopBeats)") { system.toggleLoop(deck) }.foregroundStyle(player.loopEnabled ? Color.green : Color.white).help("Toggle beat loop; requires confirmed BPM") Button("›") { deck.loopBeats = min(32,deck.loopBeats*2) } } } private var headphones: some View { Button { system.startPreview(deck) } label: { Image(systemName:"headphones") }.foregroundStyle(system.previewDeck == deck.letter ? Color.green : Color.white) } var body: some View { VStack(spacing:6) { HStack(spacing:8) { if deck.letter == "A" { playButton;cueButton;loopButtons;headphones;Spacer(minLength:0) } else { Spacer(minLength:0);headphones;loopButtons;cueButton;playButton } }.controlSize(.regular) HStack(spacing:6) { if deck.letter == "B" { Spacer(minLength:0) } ForEach(0..<4) { index in Button("\(index+1)") { system.hotCue(index,deck:deck) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : DJStyle.color(deck.letter)).contextMenu { Button("Clear hot cue") { player.hotCue(index,clear:true) } } } Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() } Menu { Button("Stop") { player.stopDeck() };Button("Exit loop") { player.clearLoop() };Button("Nudge back") { player.jog(-0.05) };Button("Nudge forward") { player.jog(0.05) } } label: { Image(systemName:"ellipsis") } if deck.letter == "A" { Spacer(minLength:0) } }.controlSize(.mini) }.buttonStyle(DJConsoleButtonStyle()).frame(maxWidth:.infinity).disabled(!player.canJog) } } private struct DJCentreWaveform: View { @ObservedObject var deck: DJChannel @ObservedObject var player: Player let letter: String let color: Color private var bpm: Double? { deck.bpmConfirmed ? deck.bpm : nil } var settings = DJPreferences() @State private var scrubStart: Double? var body: some View { GeometryReader { geometry in Canvas { context,size in guard !player.waveform.isEmpty,player.duration > 0 else { return } let span = settings.waveformSeconds,start = player.time-span/2,count = max(1,Int(size.width/2)) for second in 0...8 { let x = Double(second)/8*size.width context.fill(Path(CGRect(x:x,y:0,width:0.5,height:size.height)),with:.color(.white.opacity(settings.showTimeGrid ? 0.15 : 0))) } for i in 0..= 0,seconds < player.duration else { continue } let index = min(player.waveform.count-1,Int(seconds/player.duration*Double(player.waveform.count))) let height = max(1,Double(player.waveform[index])*size.height*0.85) var shade = color if settings.spectralWaveforms,player.waveformBands.indices.contains(index) { let band = player.waveformBands[index],sum = max(0.0001,band.low+band.mid+band.high) shade = Color(hue:Double(band.mid/sum)*0.38+Double(band.high/sum)*0.72,saturation:0.85,brightness:0.95) } context.fill(Path(CGRect(x:Double(i)*size.width/Double(count),y:(size.height-height)/2,width:1.5,height:height)),with:.color(shade)) } DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil) context.fill(Path(CGRect(x:size.width/2,y:0,width:1.5,height:size.height)),with:.color(.red)) }.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) } .contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in if scrubStart == nil { scrubStart = player.time-settings.waveformSeconds/2 };player.seek((scrubStart ?? 0)+Double(value.location.x/max(1,geometry.size.width))*settings.waveformSeconds) }.onEnded { _ in scrubStart = nil }) }.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time)) } } struct DJPreferencesPane: View { @ObservedObject var system: DJSystem let category: String var body: some View { ScrollView { VStack(alignment:.leading,spacing:26) { switch category { case "General": general case "Devices": devices case "Sound": sound case "Library": library case "Appearance": appearance case "MIDI": midi default: advanced } }.padding(26).frame(maxWidth:.infinity,alignment:.leading) }.alert("DJ settings",isPresented:Binding(get:{system.error != nil},set:{if !$0 { system.error = nil }})) { Button("OK") { system.error = nil } } message: { Text(system.error ?? "") } } private var general: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Song Loading") { VStack(alignment:.leading,spacing:14) { Toggle("Start playback when loading a track",isOn:$system.settings.autoPlayOnLoad) Toggle("Reset EQ and filter on song load",isOn:$system.settings.resetEQOnLoad) Toggle("Protect active deck",isOn:$system.settings.protectPlayingDeck) Text("Protect active deck requires pausing a playing deck before replacing its track.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Sync Mode") { VStack(alignment:.leading,spacing:10) { LabeledContent("Sync type",value:"Deck A master");Text("Both decks follow deck A’s effective BPM while Sync is active. A second click restores their previous tempos. BPM can be estimated from audio, entered or tapped; estimates may need correction. Beat-grid phase alignment is not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Tempo") { HStack(spacing:24) { Picker("Slider range",selection:$system.settings.tempoRange) { Text("±8%").tag(8.0);Text("±16%").tag(16.0);Text("±50%").tag(50.0) }.frame(width:250);Toggle("Invert slider",isOn:$system.settings.invertTempo) }.padding(12) } } } private var devices: some View { PreferencesSection("DJ Audio Devices") { VStack(alignment:.leading,spacing:18) { Picker("Main output",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("None").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } } HStack { Text("Preview volume");Slider(value:$system.cueLevel,in:0...1) } Button("Refresh devices") { system.refreshDevices() } Text("Independent headphone playback requires a different device from main output. Each device uses its default stereo pair. Multichannel master/cue pairs, booth output, microphone input and external mixer routing are not implemented. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary) }.padding(12) }.onAppear { system.refreshDevices() } } private var sound: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Mixer") { VStack(alignment:.leading,spacing:16) { Picker("Crossfader curve",selection:$system.settings.crossfaderCurve) { Text("Equal power").tag("Equal power");Text("Linear").tag("Linear") }.frame(width:380) LabeledContent("EQ type",value:"Three-band shelf / parametric EQ") LabeledContent("Filter",value:"Low-pass / high-pass · centre bypass") }.padding(12) } PreferencesSection("Audio Output") { VStack(alignment:.leading,spacing:12) { Picker("Output headroom",selection:$system.settings.headroomDB) { Text("None").tag(0.0);Text("−3 dB").tag(3.0);Text("−6 dB").tag(6.0);Text("−9 dB").tag(9.0) }.frame(width:350) Text("Headroom reduces both DJ decks before the master mix. An audio limiter and automatic gain analysis are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } } } private var library: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Independent DJ Collection") { VStack(alignment:.leading,spacing:14) { LabeledContent("Tracks",value:"\(system.songs.count)");LabeledContent("Playlists",value:"\(system.playlists.count)") Text(system.root.path).font(.callout).textSelection(.enabled) Button("Show DJ library in Finder") { NSWorkspace.shared.open(system.root) } Text("Import from the DJ browser. Files and playlists are stored separately from karaoke. Imports scan subfolders and verify copied audio. BPM is estimated from audio on load; key analysis, Spotify playback and watched-folder imports are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Library Display") { VStack(spacing:14) { HStack { Text("Font size").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryFont,in:10...16,step:1);Text("\(Int(system.settings.libraryFont)) pt").frame(width:45) };HStack { Text("Row height").frame(width:100,alignment:.leading);Slider(value:$system.settings.libraryRowHeight,in:24...44,step:2);Text("\(Int(system.settings.libraryRowHeight)) pt").frame(width:45) } }.padding(12) } } } private var appearance: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Jog Wheels") { Toggle("Attach rotating tape marker",isOn:$system.settings.platterMarker).padding(12) } PreferencesSection("Waveforms") { VStack(alignment:.leading,spacing:14) { Toggle("Use frequency colours in scrolling waveforms",isOn:$system.settings.spectralWaveforms) Toggle("Show time grid",isOn:$system.settings.showTimeGrid) Toggle("Dim inactive deck",isOn:$system.settings.dimInactiveDeck) Picker("Visible window",selection:$system.settings.waveformSeconds) { ForEach([4.0,8,16,32],id:\.self) { Text("\(Int($0)) seconds").tag($0) } }.frame(width:340) }.padding(12) } } } private var midi: some View { VStack(alignment:.leading,spacing:20) { PreferencesSection("MIDI Controller") { Text("Select a source and learn controls using the MIDI button above the DJ console. Mappings save per controller. Standard note buttons and 7-bit CC controls are supported; jog encoders can use relative modes.").padding(12) } DJMappingSheet(midi:system.midi,embedded:true) } } private var advanced: some View { VStack(alignment:.leading,spacing:26) { PreferencesSection("Cue Points & Loops") { VStack(alignment:.leading,spacing:14) { Toggle("Start playback when triggering a hot cue",isOn:$system.settings.autoPlayHotCue) Text("Hot cues and loop points belong to the loaded track session. Manual IN/OUT loops and BPM-based beat loops are supported. Beat-grid quantization and saving cue/loop data per song are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) } PreferencesSection("Playback") { Text("Platter dragging performs seek-based jogging. DVS, touch scratching, recording, Neural Mix and cloud synchronization are not implemented.").font(.callout).foregroundStyle(.secondary).padding(12) } } } } private struct DJConsoleButtonStyle: ButtonStyle { let accent: Color? let active: Bool @Environment(\.controlSize) private var size @Environment(\.isEnabled) private var enabled init(_ accent: Color? = nil,active: Bool = false) { self.accent = accent;self.active = active } func makeBody(configuration: Configuration) -> some View { configuration.label.padding(.horizontal,size == .mini ? 6 : 12).padding(.vertical,size == .mini ? 3 : 7) .background(active ? (accent ?? .blue).opacity(0.45) : Color(white:configuration.isPressed ? 0.20 : 0.12),in:RoundedRectangle(cornerRadius:5)) .overlay(RoundedRectangle(cornerRadius:5).stroke(accent ?? .black.opacity(0.8),lineWidth:accent == nil ? 1 : 2)) .opacity(enabled ? 1 : 0.35) } } struct DJReplacement: Identifiable { let id = UUID() let deck: String let song: Song? let urls: [URL] var title: String { song?.title ?? urls.first?.lastPathComponent ?? "track" } } @MainActor extension DJSystem { func acceptDrop(_ providers: [NSItemProvider],into letter: String) -> Bool { let files = providers.filter{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)} if !files.isEmpty { Task { @MainActor in do { let urls = try await DJFinderDrop.urls(files);requestFileLoad(urls,into:letter) } catch { self.error = error.localizedDescription } } return true } guard let provider = providers.first(where:{$0.canLoadObject(ofClass:NSString.self)}) else { return false } Task { @MainActor in do { let reference = try await SongDragReference.load(provider) guard reference.library == dragSession,let song = songs.first(where:{$0.id == reference.song}) else { throw LibraryError.message("Drag from the DJ library or drop an audio file from Finder.") } load(song,into:letter) } catch { self.error = error.localizedDescription } } return true } func requestFileLoad(_ urls: [URL],into letter: String,override: Bool = false) { guard !urls.isEmpty else { return } if channel(letter).player.playing,!override { pendingReplacement = .init(deck:letter,song:nil,urls:urls);return } guard !importing else { status = "Wait for the current DJ import to finish.";return } importFiles(urls,loadInto:letter,overridePlaying:override) } func confirmReplacement(_ captured: DJReplacement? = nil) { guard let request = captured ?? pendingReplacement else { return };pendingReplacement = nil if let song = request.song { load(song,into:request.deck,overridePlaying:true) } else { requestFileLoad(request.urls,into:request.deck,override:true) } } } /// Conservative onset-envelope estimate; silence and steady tones return no BPM. enum DJBPM { static func estimate(_ envelope: [Double],rate: Double = 200) -> Double? { guard envelope.count >= Int(rate*4),rate > 0 else { return nil } let average = envelope.reduce(0,+)/Double(envelope.count) guard average > 0.00001 else { return nil } let onset = envelope.indices.map { i in i == 0 ? 0 : max(0,envelope[i]-envelope[i-1]) } let energy = onset.reduce(0){$0+$1*$1} guard sqrt(energy/Double(onset.count)) > average*0.035 else { return nil } var bestScore = 0.0,bestLag = 0 let lower = Int(rate*60/180),upper = Int(rate*60/70) for lag in lower...upper { var product = 0.0,first = 0.0,second = 0.0 for i in lag.. bestScore { bestScore = score;bestLag = lag } } guard bestScore > 0.12,bestLag > 0 else { return nil } return (60*rate/Double(bestLag)*10).rounded()/10 } static func analyse(_ url: URL,cancelled: () -> Bool = { false }) throws -> Double? { let file = try AVAudioFile(forReading:url),format = file.processingFormat guard let buffer = AVAudioPCMBuffer(pcmFormat:format,frameCapacity:16384),format.channelCount > 0 else { return nil } let hop = max(1,Int(format.sampleRate/200)),limit = Int64(format.sampleRate*90) var envelope = [Double](),sum = 0.0,count = 0 while file.framePosition < min(file.length,limit) { if cancelled() { throw CancellationError() } try file.read(into:buffer) guard buffer.frameLength > 0,let channels = buffer.floatChannelData else { break } for i in 0.. NSImage? { let asset = AVURLAsset(url:URL(fileURLWithPath:song.audio)) if let items = try? await asset.load(.commonMetadata),let item = items.first(where:{$0.commonKey == .commonKeyArtwork}),let data = try? await item.load(.dataValue),data.count <= 20_000_000,let image = NSImage(data:data) { return image } return sidecar(song).flatMap{NSImage(contentsOf:$0)} } static func sidecar(_ song: Song) -> URL? { let sources = [song.originalPath,song.audio].filter{!$0.isEmpty}.map{URL(fileURLWithPath:$0)} for source in sources { let folder = source.deletingLastPathComponent(),stem = source.deletingPathExtension().lastPathComponent.lowercased() guard let files = try? FileManager.default.contentsOfDirectory(at:folder,includingPropertiesForKeys:[.fileSizeKey,.isRegularFileKey],options:[.skipsHiddenFiles]) else { continue } let candidates = files.filter { url in guard ["jpg","jpeg","png"].contains(url.pathExtension.lowercased()),let values = try? url.resourceValues(forKeys:[.fileSizeKey,.isRegularFileKey]) else { return false } return values.isRegularFile == true && (values.fileSize ?? Int.max) <= 20_000_000 } func priority(_ url: URL) -> Int { let name = url.deletingPathExtension().lastPathComponent.lowercased();return name == stem ? 0 : ["cover","folder","front","artwork"].contains(name) ? 1 : 2 } if let first = candidates.sorted(by:{priority($0) == priority($1) ? $0.lastPathComponent < $1.lastPathComponent : priority($0) < priority($1)}).first { return first } } return nil } } private struct DJCueEditor: View { @ObservedObject var system: DJSystem var body: some View { let deck = system.channel(system.cueEditor ?? "A") VStack(alignment:.leading,spacing:18) { Text("Deck \(deck.letter) · Cue Point").font(.title2.bold()) Text(deck.song?.title ?? "No track").foregroundStyle(.secondary) HStack { Text("Position in seconds");TextField("Seconds",value:$system.cueDraft,format:.number.precision(.fractionLength(2))).textFieldStyle(.roundedBorder).frame(width:100);Text("/ "+DJStyle.stamp(deck.player.duration)).foregroundStyle(.secondary) } Slider(value:$system.cueDraft,in:0...max(0.001,deck.player.duration)).accessibilityLabel("Cue point position") Button("Use Current Playhead") { system.cueDraft = deck.player.time } Text("Saving changes the cue point without moving or pausing playback. The CUE button pauses and returns to this position.").font(.caption).foregroundStyle(.secondary) HStack { Button("Cancel") { system.cueEditing = false };Spacer();Button("Save Cue Point") { system.saveCue() }.keyboardShortcut(.defaultAction) } }.padding(24).frame(width:470) } } /// Protocol facts adapted from josefveiga/Numark-Mixtrack-Go-mapping-for-Mixxx. /// XML is authoritative for the right Mode button (95 00); the JS table has a conflicting entry. struct MixtrackGoProtocol { struct Event { var action: String;var value: Double = 1;var relative = false } var modes = [0,0] var lowEQ = true private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false] static func mappings(source: Int32) -> [DJMIDIMapping] { var result = [DJMIDIMapping]() func add(_ status: UInt8,_ number: UInt8,_ action: String,_ mode: String = "Absolute") { result.append(.init(source:source,status:status,number:number,action:action,mode:mode)) } add(0xBF,0x0A,"Master");add(0xBF,0x0C,"Cue volume");add(0xBF,0x08,"Crossfader") add(0xBF,0x00,"Browse","Relative signed");add(0x9F,0x07,"Preview selected") for i in 0..<2 { let d = i == 0 ? "A" : "B",note = UInt8(0x90+i),cc = UInt8(0xB0+i) add(0x9F,UInt8(0x02+i),d+" Load") add(cc,0x16,d+" Level");add(cc,0x06,d+" Jog ticks","Relative signed") add(note,0x1B,d+" Headphones");add(note,0x00,d+" Play");add(note,0x01,d+" Cue button");add(note,0x02,d+" Sync") add(note,0x03,d+" Sync");add(note,0x04,d+" Cue play");add(note,0x05,d+" Cue play") } return result } mutating func consume(_ message: DJMIDIMessage) -> [Event]? { let status = message.status,number = message.data1,value = message.data2 if status == 0x9F,number == 0x48 { lowEQ.toggle();return [.init(action:"Notice: Knobs now control "+(lowEQ ? "Low EQ" : "Filter"))] } if [UInt8(0xB0),0xB1].contains(status) { let index = Int(status & 1),deck = index == 0 ? "A" : "B" if number == 0x09 || number == 0x29 { if number == 0x09 { msb[index] = Int(value);gotMSB[index] = true } else { lsb[index] = Int(value);gotLSB[index] = true } guard gotMSB[index],gotLSB[index] else { return [] } let raw = (msb[index] << 7) | lsb[index],signed = max(-1,min(1,Double(raw-8192)/8191)) return [.init(action:deck+" Tempo",value:0.5+signed/2)] } if number == 0x1A { return [.init(action:deck+(lowEQ ? " Low" : " Filter"),value:Double(value)/127)] } } if [UInt8(0x94),0x95].contains(status) { let index = Int(status & 1),deck = index == 0 ? "A" : "B" if number == 0 { modes[index] = (modes[index]+1)%5;return [.init(action:"Notice: "+deck+" pad mode · "+["Hot cues","Loops","FX (unsupported)","Sampler (unsupported)","Stems (unsupported)"][modes[index]])] } let shifted = (0x1C...0x1F).contains(number),first: UInt8 = shifted ? 0x1C : 0x14 guard (first...(first+3)).contains(number) else { return nil } let pad = Int(number-first) if modes[index] == 0 { return [.init(action:deck+(shifted ? " Clear cue " : " Hot cue ")+String(pad+1))] } if modes[index] == 1 { return [.init(action:deck+(shifted ? " Loop off" : " Loop "+String(1 << pad)))] } return [.init(action:"Notice: This pad mode requires an FX, sampler or stems engine that is not implemented.")] } if [UInt8(0x90),0x91].contains(status),[UInt8(0x46),0x47,0x48,0x49].contains(number) { return [.init(action:"Notice: Acapella/instrumental stems are not implemented.")] } return nil } func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) -> [UInt8] { var bytes = [UInt8]() for i in 0..<2 { let status = UInt8(0x90+i),pad = UInt8(0x94+i),loaded = i == 0 ? aLoaded : bLoaded,playing = i == 0 ? aPlaying : bPlaying bytes += [status,0x00,playing ? 127 : (loaded ? 1 : 0),status,0x01,loaded ? 1 : 0,status,0x02,sync ? 127 : 0] bytes += [UInt8(0x80+i),0x1B,cue == (i == 0 ? "A" : "B") ? 127 : 1] for mode in 0..<4 { let on = modes[i] == 0 ? mode == 0 : modes[i] == 1 ? mode == 1 : modes[i] == 2 ? (mode == 1 || mode == 2) : modes[i] == 3 ? mode == 2 : mode == 3 bytes += [pad,UInt8(mode+1),on ? 127 : 1] } } return bytes } } enum DJFinderDrop { static func urls(_ providers: [NSItemProvider]) async throws -> [URL] { var urls = [URL]() for provider in providers { let url: URL = try await withCheckedThrowingContinuation { continuation in provider.loadDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier) { data,_ in if let data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url);return } provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in if let url = item as? URL,url.isFileURL { continuation.resume(returning:url) } else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil),url.isFileURL { continuation.resume(returning:url) } else { continuation.resume(throwing:error ?? LibraryError.message("Could not read an absolute local file URL from Finder. Drop the audio file itself or import it with Import Files / Folder.")) } } } } urls.append(url) } return urls } } // BPM-relative guides start at source time zero; these are not analysed downbeats. enum DJWaveMarkers { static func beats(start: Double,span: Double,bpm: Double?,width: Double) -> [Int] { guard let bpm,bpm.isFinite,bpm > 0,start.isFinite,span.isFinite,span > 0,width > 0 else { return [] } let interval = 60/bpm,first = max(0,Int(ceil(start/interval))),last = Int(floor((start+span)/interval)) guard last >= first else { return [] } let strideBy = max(1,Int(ceil(span/interval/max(1,width/5)))) return Array(stride(from:first,through:last,by:strideBy)) } static func draw(context: inout GraphicsContext,size: CGSize,start: Double,span: Double,bpm: Double?,loop: ClosedRange?) { guard span > 0 else { return } if let bpm { for beat in beats(start:start,span:span,bpm:bpm,width:size.width) { let x = (Double(beat)*60/bpm-start)/span*size.width context.fill(Path(CGRect(x:x,y:0,width:beat % 4 == 0 ? 1.2 : 0.6,height:size.height)),with:.color(.white.opacity(beat % 4 == 0 ? 0.5 : 0.22))) } } if let loop { let left = max(0,(loop.lowerBound-start)/span*size.width),right = min(size.width,(loop.upperBound-start)/span*size.width) if right > left { let rect = CGRect(x:left,y:0,width:right-left,height:size.height) context.fill(Path(rect),with:.color(.green.opacity(0.22))) context.stroke(Path(rect.insetBy(dx:0.8,dy:0.8)),with:.color(.green),lineWidth:1.5) } for (time,label) in [(loop.lowerBound,"IN"),(loop.upperBound,"OUT")] { let x = (time-start)/span*size.width if x >= 0,x <= size.width { context.fill(Path(CGRect(x:x,y:0,width:2,height:size.height)),with:.color(.green)) context.draw(Text(label).font(.system(size:8,weight:.bold)).foregroundColor(.green),at:CGPoint(x:min(size.width-12,max(12,x)),y:7)) } } } } }