diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift index 27d8e5d..c576801 100644 --- a/KJamsClone/DJWorkspace.swift +++ b/KJamsClone/DJWorkspace.swift @@ -269,6 +269,7 @@ struct DJDeckControls: View { struct StudioSettingsPane: View { var showHeading = true + var section: String? = nil @AppStorage("studioAppearance") private var appearance = "system" @ObservedObject var model: AppModel @State private var port = "" @@ -276,7 +277,8 @@ struct StudioSettingsPane: View { ScrollView { VStack(alignment:.leading,spacing:22) { if showHeading { HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } } - GroupBox("Appearance") { + if section == nil || section == "Appearance" { + PreferencesSection("Appearance") { HStack { Picker("Appearance",selection:$appearance) { Text("System").tag("system") @@ -287,7 +289,9 @@ struct StudioSettingsPane: View { }.padding(10) Text("System follows your Mac. The waveform and video output remain dark for clear monitoring.").font(.caption).foregroundStyle(.secondary).padding([.horizontal,.bottom],10) } - GroupBox("Playback & Jog") { + } + if section == nil || section == "General" { + PreferencesSection("Playback & Jog") { VStack(alignment:.leading,spacing:12) { Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance) Toggle("Repeat on startup",isOn:$model.studio.repeatTrack) @@ -297,14 +301,18 @@ struct StudioSettingsPane: View { Text("Option-scroll over the waveform to jog. Cue, hot cues and loops belong to the loaded deck and reset on track change.").font(.caption).foregroundStyle(.secondary) }.padding(10) } - GroupBox("Hosting · \(model.currentVenueName)") { + } + if section == nil || section == "General" { + PreferencesSection("Hosting · \(model.currentVenueName)") { VStack(alignment:.leading,spacing:12) { Toggle("Fade song endings",isOn:Binding(get:{model.hostSettings.autoFade},set:{model.hostSettings.autoFade = $0;model.saveHostSettings()})) Stepper("Fade duration: \(Int(model.hostSettings.fadeSeconds)) seconds",value:Binding(get:{model.hostSettings.fadeSeconds},set:{model.hostSettings.fadeSeconds = $0;model.saveHostSettings()}),in:1...15,step:1) Picker("Between singers",selection:Binding(get:{model.hostSettings.intermission},set:{model.hostSettings.intermission = $0;model.saveHostSettings()})) { Text("No automatic slide").tag(nil as Int64?);ForEach(model.showScreens) { Text($0.title).tag(Optional($0.id)) } } }.padding(10) }.disabled(model.currentVenue == nil) - GroupBox("Background Music") { + } + if section == nil || section == "Sound" { + PreferencesSection("Background Music") { VStack(alignment:.leading,spacing:12) { HStack { Text(model.studio.backgroundPath.map{URL(fileURLWithPath:$0).lastPathComponent} ?? "No track selected").lineLimit(1);Spacer();Button("Choose Track…") { model.chooseBackground() };Button("Clear") { model.studio.backgroundPath = nil;model.studio.backgroundBookmark = nil }.disabled(model.studio.backgroundPath == nil) } Toggle("Automatically play background music between singers",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})) @@ -313,11 +321,15 @@ struct StudioSettingsPane: View { HStack { Text("Fade out");Slider(value:Binding(get:{model.studio.backgroundFadeOut ?? 0},set:{model.studio.backgroundFadeOut = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeOut ?? 0)).monospacedDigit().frame(width:65) } Text("Play uses Fade in; background Pause and Stop use Fade out. Zero disables a fade. Singer audio waits for the background fade-out to finish.").font(.caption).foregroundStyle(.secondary) HStack { Text("Background level");Slider(value:$model.studio.backgroundTrim,in:0...1);Text("\(Int(model.studio.backgroundTrim*100))%").monospacedDigit() } - Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Global EQ and master apply to both decks.").font(.caption).foregroundStyle(.secondary) + Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Karaoke master and EQ apply to singer and background audio; DJ has separate controls.").font(.caption).foregroundStyle(.secondary) }.padding(10) } + } + if section == nil || section == "Sound" { GlobalMixerSettings(player:model.player) - GroupBox("Imported Media Storage") { + } + if section == nil || section == "Library" { + PreferencesSection("Imported Media Storage") { VStack(alignment:.leading,spacing:12) { Label(model.managedMediaDirectory.path,systemImage:"externaldrive").font(.callout).textSelection(.enabled).lineLimit(3) HStack { @@ -329,7 +341,9 @@ struct StudioSettingsPane: View { Text("The small library database, singer lists, settings and waveform cache stay on this Mac.").font(.caption).foregroundStyle(.secondary) }.padding(10) } - GroupBox("Singer Web & Library") { + } + if section == nil || section == "Advanced" { + PreferencesSection("Singer Web & Library") { VStack(alignment:.leading,spacing:12) { HStack { Text("Default server port");TextField("8080",text:$port).frame(width:90);Button("Apply Port") { if let value = Int(port),(1024...65535).contains(value) { model.studio.serverPort = value } else { model.error = "Use a port from 1024 to 65535" } } } Text("Used next time you start Singer Web. The server remains off until started explicitly.").font(.caption).foregroundStyle(.secondary) @@ -337,6 +351,7 @@ struct StudioSettingsPane: View { Text("Audio output follows the Mac’s current sound output. Configure hardware output in macOS Sound settings.").font(.caption).foregroundStyle(.secondary) }.padding(10) } + } }.padding(22) }.background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(model.studio.serverPort) } } @@ -610,24 +625,72 @@ struct RotationSongBrowser: View { } } + +struct PreferencesSection: View { + let title: String + @ViewBuilder let content: () -> Content + init(_ title: String,@ViewBuilder content: @escaping () -> Content) { self.title = title;self.content = content } + var body: some View { + VStack(alignment:.leading,spacing:12) { + HStack(spacing:8) { Text(title).font(.headline);Rectangle().fill(Color.primary.opacity(0.12)).frame(height:1) } + content().frame(maxWidth:.infinity,alignment:.leading) + }.frame(maxWidth:.infinity,alignment:.leading) + } +} struct StudioSettingsDialog: View { @ObservedObject var model: AppModel + @State var scope = "Karaoke" + @State var category = "General" @Environment(\.dismiss) private var dismiss + private var categories: [String] { scope == "DJ" ? ["General","Devices","Sound","Library","Appearance","MIDI","Advanced"] : ["General","Devices","Sound","Library","Appearance","Singer Web","Advanced"] } + private func icon(_ name: String) -> String { ["General":"gearshape","Devices":"hifispeaker","Sound":"waveform","Library":"music.note.house","Appearance":"eye","MIDI":"pianokeys","Advanced":"gearshape.2","Singer Web":"network"][name] ?? "gearshape" } var body: some View { VStack(spacing:0) { - HStack(spacing:14) { - Image(systemName:"slider.horizontal.3").font(.title2).foregroundStyle(Color.accentColor) - VStack(alignment:.leading,spacing:4) { - Text("Studio Settings").font(.title2.bold()) - Text("Karaoke playback, background music and hosting").font(.callout).foregroundStyle(.secondary) + HStack { + Text(category).font(.headline).frame(maxWidth:.infinity,alignment:.leading) + Picker("Settings for",selection:$scope) { Text("Karaoke").tag("Karaoke");Text("DJ").tag("DJ") }.pickerStyle(.segmented).frame(width:220) + }.padding(.horizontal,24).padding(.top,18).padding(.bottom,10) + HStack(spacing:8) { + ForEach(categories,id:\.self) { name in + Button { category = name } label: { + VStack(spacing:5) { Image(systemName:icon(name)).font(.system(size:24));Text(name).font(.system(size:11,weight:.medium)) }.frame(maxWidth:.infinity).frame(height:58).foregroundStyle(category == name ? Color.accentColor : Color.secondary).background(category == name ? Color.primary.opacity(0.08) : .clear,in:RoundedRectangle(cornerRadius:9)) + }.buttonStyle(.plain).accessibilityLabel(scope+" "+name+" settings") } - Spacer() - }.padding(24) + }.padding(.horizontal,24).padding(.bottom,16) Divider() - StudioSettingsPane(showHeading:false,model:model) + if scope == "DJ" { DJPreferencesPane(system:model.dj,category:category) } + else if category == "Singer Web" { SingerServerPane(model:model,server:model.singerServer) } + else if category == "Devices" { KaraokeDevicesPreferences(model:model) } + else { StudioSettingsPane(showHeading:false,section:category,model:model).id(category) } Divider() - HStack { Text("Changes save automatically on this Mac").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) - }.frame(width:760,height:680).background(Color(nsColor:.windowBackgroundColor)) + HStack { Text(scope+" settings · Changes save automatically").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) }.padding(.horizontal,24).padding(.vertical,16) + }.frame(width:850,height:700).background(Color(nsColor:.windowBackgroundColor)) + .onChange(of:scope) { _ in model.dj.midi.learning = nil;if !categories.contains(category) { category = "General" } } + .onChange(of:category) { _ in model.dj.midi.learning = nil } + .onDisappear { model.dj.midi.learning = nil } .alert("Settings",isPresented:Binding(get:{model.error != nil},set:{if !$0 { model.error = nil }})) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") } } } +private struct KaraokeDevicesPreferences: View { + @ObservedObject var model: AppModel + var body: some View { + ScrollView { + PreferencesSection("Karaoke Audio Output") { + VStack(alignment:.leading,spacing:16) { + Picker("Main output",selection:Binding(get:{model.karaokeOutputUID},set:{model.routeKaraokeOutput($0)})) { Text("System default").tag("");ForEach(model.dj.devices) { Text($0.name).tag($0.uid) } }.frame(maxWidth:520) + Button("Refresh devices") { model.dj.refreshDevices() } + Text("Applies to the singer deck. Background music follows the system output. DJ audio is configured separately under DJ → Devices.").font(.callout).foregroundStyle(.secondary) + }.padding(12) + }.padding(24) + }.onAppear { model.dj.refreshDevices() } + } +} + +@MainActor extension AppModel { + func routeKaraokeOutput(_ uid: String) { + perform { + guard let id = uid.isEmpty ? DJAudioDevice.defaultOutput() : dj.devices.first(where:{$0.uid == uid})?.id else { throw LibraryError.message("Output device unavailable") } + try player.routeOutput(id);karaokeOutputUID = uid + } + } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 8085313..081a864 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -2,11 +2,14 @@ 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) -> (Double, Double) { + 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? { @@ -16,12 +19,45 @@ enum DJMix { } } +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 @@ -33,25 +69,36 @@ enum DJMix { 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 = [] + self.song = song;bpmConfirmed = false;bpmEstimated = false;bpm = 120;taps = [];loadTrackInfo(song) } - func unload() { player.stop();song = nil;bpmConfirmed = false } + 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 } + 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) - 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() } + player.defineLoop(start:start,end:end) + } } @@ -92,11 +139,20 @@ struct DJAudioDevice: Identifiable, Equatable { 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 @@ -110,18 +166,24 @@ struct DJAudioDevice: Identifiable, Equatable { 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) 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]) { + 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") @@ -129,19 +191,21 @@ struct DJAudioDevice: Identifiable, Equatable { 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) - 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 + 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 + 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) } } } } @@ -149,17 +213,20 @@ struct DJAudioDevice: Identifiable, Equatable { 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 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) { + func load(_ song: Song,into letter: String,overridePlaying: Bool = false) { perform { let deck = channel(letter) - // Silence first; loading never starts public playback. + 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" } } @@ -195,9 +262,37 @@ struct DJAudioDevice: Identifiable, Equatable { } } 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." + 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.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 } + 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 == "Crossfader" { crossfader = min(1,max(0,value));return } @@ -210,7 +305,7 @@ struct DJAudioDevice: Identifiable, Equatable { case "Cue": p.returnToCue() case "Set cue": p.setCue() case "Sync": sync(d) - case "Loop": d.beatLoop() + 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 @@ -219,12 +314,12 @@ struct DJAudioDevice: Identifiable, Equatable { 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 "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": 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) + 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 } } @@ -342,6 +437,7 @@ private enum DJStyle { struct DJConsole: View { @ObservedObject var model: AppModel @ObservedObject var system: DJSystem + @State private var settingsModal = false @State private var routing = false @State private var mapping = false @State private var query = "" @@ -364,11 +460,7 @@ struct DJConsole: 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")}) - } + DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}) browser.frame(height:130) if let progress = system.importProgress { HStack { @@ -382,12 +474,14 @@ struct DJConsole: View { }.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) } + .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() } + } 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() } @@ -404,21 +498,11 @@ struct DJConsole: View { 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 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) { @@ -441,12 +525,12 @@ struct DJConsole: View { 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.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") - }.tag(song.id).buttonStyle(.bordered).controlSize(.mini) + }.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) } } @@ -485,103 +569,36 @@ struct DJConsole: View { } } -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 { +private struct DJDeckSurfaces { @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 - } + 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) } - // 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) { + 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:5) { - DJKnob(label:"GAIN",value:$deck.gain,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)}) - DJKnob(label:"LEVEL",value:$deck.level,range:0...1,reset:0.8,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: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) + }.frame(width:88) } } -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 @@ -599,8 +616,8 @@ private struct DJKnob: View { Circle().trim(from:0.12,to:0.88).stroke(color.opacity(0.35),style:StrokeStyle(lineWidth:2,lineCap:.round)).rotationEffect(.degrees(90)) Capsule().fill(color).frame(width:2,height:9).offset(y:-7).rotationEffect(.degrees(-135+fraction*270)) }.frame(width:29,height:29).contentShape(Circle()) - .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 } + .highPriorityGesture(TapGesture(count:2).onEnded { value = reset;initial = nil }) + .simultaneousGesture(DragGesture(minimumDistance:3).onChanged { drag in if initial == nil { initial = value };value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(drag.translation.height)*(range.upperBound-range.lowerBound)/120)) }.onEnded{_ in initial = nil}) Text(label).font(.system(size:8,weight:.medium)).foregroundStyle(.secondary) }.frame(minWidth:36).help("\(label): \(format(value)). Drag vertically; double-click to reset.") .accessibilityElement(children:.ignore).accessibilityLabel(label).accessibilityValue(format(value)) @@ -611,6 +628,7 @@ 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 @@ -620,7 +638,10 @@ private struct DJPlatter: View { 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) + 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 @@ -655,52 +676,9 @@ private struct DJWaveform: View { }.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 + var embedded = false @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"] @@ -730,7 +708,474 @@ private struct DJMappingSheet: View { } }.frame(height:200) Text("Mappings save per MIDI source ID. Use relative mode only for Jog encoders. Supported: note buttons and 7-bit CCs. Load uses the selected DJ library row. Button releases are ignored. Controller LEDs, motor feedback, 14-bit controls, scratching and vendor-specific handshakes are not implemented.").font(.caption).foregroundStyle(.secondary) - HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } + if !embedded { HStack { Spacer();Button("Done") { midi.learning = nil;dismiss() }.keyboardShortcut(.defaultAction) } } }.padding(24).frame(width:660) } } + +/// Native mouse/keyboard/accessibility behaviour with a rectangular mixer-fader cap. +struct DJHardwareFader: NSViewRepresentable { + @Binding var value: Double + let label: String + var vertical = false + func makeCoordinator() -> 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(player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) + DJCentreWaveform(player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings) + }.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).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 player: Player + let letter: String + let color: Color + 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)) + } + 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:"Tempo matching");Text("Uses entered or tapped BPM. Automatic beat analysis, beat-grid alignment and persistent sync lock are not implemented.").font(.caption).foregroundStyle(.secondary) }.padding(12) + } + 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. Spotify playback, automatic BPM/key analysis 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 { + if let provider = providers.first(where:{$0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)}) { + Task { @MainActor in + do { + let url: URL = try await withCheckedThrowingContinuation { continuation in + provider.loadItem(forTypeIdentifier:UTType.fileURL.identifier,options:nil) { item,error in + if let url = item as? URL { continuation.resume(returning:url) } + else if let data = item as? Data,let url = URL(dataRepresentation:data,relativeTo:nil) { continuation.resume(returning:url) } + else { continuation.resume(throwing:error ?? LibraryError.message("Could not read the Finder file.")) } + } + } + guard url.isFileURL else { throw LibraryError.message("Drop a local audio file from Finder.") } + requestFileLoad([url],into:letter) + } catch { self.error = error.localizedDescription } + } + 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() { + guard let request = 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) + } +} diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift index f51684b..d7aca2c 100644 --- a/KJamsClone/KJMixProApp.swift +++ b/KJamsClone/KJMixProApp.swift @@ -65,6 +65,7 @@ struct SongDragReference: Codable, Sendable { @Published var activeSection = "Library" @Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } } let backgroundDeck = BackgroundDeck() + @Published var karaokeOutputUID = "" let dj: DJSystem var backgroundAutomationReady = false var backgroundPlaybackObserver: AnyCancellable? @@ -933,7 +934,6 @@ struct LibraryView: View { navigationRow("Venues",symbol:"building.2",count:model.venues.count) navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count) navigationRow("ShowScreens",symbol:"rectangle.on.rectangle") - navigationRow("Singer Web",symbol:"network") navigationRow("Music Stores",symbol:"bag") Button { studioSettingsModal = true } label: { Label("Settings…",systemImage:"gearshape").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain) }.font(.body).listRowSeparator(.hidden).listRowInsets(EdgeInsets(top:1,leading:8,bottom:1,trailing:8)) diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 00624a8..5e549a6 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -27,7 +27,11 @@ import CoreAudio guard canJog else { return } pauseDeck();loopEnabled = false;seek(0) } - func setCue() { guard canJog else { return };cuePosition = time } + func setCue(at seconds: Double? = nil) { + guard canJog else { return } + if let seconds { guard seconds.isFinite else { return };cuePosition = min(duration,max(0,seconds)) } + else { tick();cuePosition = time } + } func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) } private func pauseDeck() { cancelPendingPlayback() @@ -45,6 +49,10 @@ import CoreAudio func markLoopOut() { guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true } + func defineLoop(start: Double,end: Double) { + guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1 else { return } + loopIn = start;loopOut = end;loopEnabled = true + } func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil } func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) } private var graphicsGeneration = UUID() @@ -241,7 +249,8 @@ import CoreAudio Task { @MainActor in guard let self, self.generation == token else { return } if self.fading { return } - if self.repeating { self.schedule(at:0,play:true) } + if self.loopEnabled,let start = self.loopIn { self.schedule(at:start,play:true) } + else if self.repeating { self.schedule(at:0,play:true) } else { self.playing = false; self.time = self.duration; self.render() self.timer?.invalidate(); self.timer = nil; self.onFinished?() diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index febd7ca..f7905b3 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1733,14 +1733,28 @@ extension KJamsCloneTests { 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) - let settings = NSHostingView(rootView:StudioSettingsDialog(model:model).preferredColorScheme(.dark)) - settings.frame = NSRect(x:0,y:0,width:760,height:680) - let settingsWindow = NSWindow(contentRect:settings.frame,styleMask:[.borderless],backing:.buffered,defer:false) - settingsWindow.appearance = NSAppearance(named:.darkAqua);settingsWindow.contentView = settings - settingsWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));settingsWindow.orderFront(nil) - defer { settingsWindow.orderOut(nil);settingsWindow.contentView = nil } - try await Task.sleep(nanoseconds:200_000_000);settings.layoutSubtreeIfNeeded() - if let rep = settings.bitmapImageRepForCachingDisplay(in:settings.bounds) { settings.cacheDisplay(in:settings.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-settings-native.png")) } + func capturePreferences(_ scope: String,_ category: String,_ path: String) async throws { + let settings = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:scope,category:category).preferredColorScheme(.dark)) + settings.frame = NSRect(x:0,y:0,width:850,height:700) + let settingsWindow = NSWindow(contentRect:settings.frame,styleMask:[.borderless],backing:.buffered,defer:false) + settingsWindow.appearance = NSAppearance(named:.darkAqua);settingsWindow.contentView = settings + settingsWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));settingsWindow.orderFront(nil) + defer { settingsWindow.orderOut(nil);settingsWindow.contentView = nil } + try await Task.sleep(nanoseconds:150_000_000);settings.layoutSubtreeIfNeeded() + if let rep = settings.bitmapImageRepForCachingDisplay(in:settings.bounds) { settings.cacheDisplay(in:settings.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:path)) } + } + try await capturePreferences("Karaoke","General","/private/tmp/kjmix-settings-native.png") + try await capturePreferences("DJ","General","/private/tmp/kjmix-dj-settings-general.png") + try await capturePreferences("DJ","Sound","/private/tmp/kjmix-dj-settings-sound.png") + try await capturePreferences("DJ","Appearance","/private/tmp/kjmix-dj-settings-appearance.png") + let console = NSHostingView(rootView:DJContinuousConsole(system:model.dj,loadA:{},loadB:{}).frame(width:1320,height:390).foregroundStyle(.white).preferredColorScheme(.dark)) + console.frame = NSRect(x:0,y:0,width:1320,height:390) + let consoleWindow = NSWindow(contentRect:console.frame,styleMask:[.borderless],backing:.buffered,defer:false) + consoleWindow.appearance = NSAppearance(named:.darkAqua);consoleWindow.contentView = console;consoleWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));consoleWindow.orderFront(nil) + defer { consoleWindow.orderOut(nil);consoleWindow.contentView = nil } + try await Task.sleep(nanoseconds:150_000_000);console.layoutSubtreeIfNeeded() + if let rep = console.bitmapImageRepForCachingDisplay(in:console.bounds) { console.cacheDisplay(in:console.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-dj-console.png")) } + } } @@ -1852,3 +1866,60 @@ extension KJamsCloneTests { XCTAssertFalse(player.playing);XCTAssertNil(player.currentSongID) } } + +extension KJamsCloneTests { + @MainActor func testNativeDJFaderOrientationAndControlActionUpdateBinding() async throws { + var value = 0.5 + let host = NSHostingView(rootView:DJHardwareFader(value:Binding(get:{value},set:{value = $0}),label:"Tempo test",vertical:true).frame(width:52,height:94)) + host.frame = NSRect(x:0,y:0,width:52,height:94) + let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false) + window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil) + defer { window.orderOut(nil);window.contentView = nil } + try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded() + func find(_ view: NSView) -> NSSlider? { if let slider = view as? NSSlider { return slider };return view.subviews.compactMap{find($0)}.first } + let slider = try XCTUnwrap(find(host)) + XCTAssertTrue(slider.isVertical);XCTAssertEqual(slider.doubleValue,0.5,accuracy:0.001) + slider.doubleValue = 0.25 + XCTAssertTrue(slider.sendAction(slider.action,to:slider.target));XCTAssertEqual(value,0.25,accuracy:0.001) + slider.doubleValue = 1 + XCTAssertTrue(slider.sendAction(slider.action,to:slider.target));XCTAssertEqual(value,1,accuracy:0.001) + } +} + +extension KJamsCloneTests { + func testDJPreferencesTempoDirectionRangesAndMixCurves() { + var prefs = DJPreferences() + XCTAssertEqual(prefs.tempo(at:0.5),1,accuracy:0.0001) + XCTAssertEqual(prefs.tempo(at:1),0.84,accuracy:0.0001) + XCTAssertEqual(prefs.position(for:1.16),0,accuracy:0.0001) + prefs.invertTempo = false;prefs.tempoRange = 8 + XCTAssertEqual(prefs.tempo(at:0),0.92,accuracy:0.0001) + XCTAssertEqual(prefs.tempo(at:1),1.08,accuracy:0.0001) + XCTAssertEqual(prefs.position(for:prefs.tempo(at:0.25)),0.25,accuracy:0.0001) + let linear = DJMix.weights(0.5,curve:"Linear") + XCTAssertEqual(linear.0,0.5);XCTAssertEqual(linear.1,0.5) + prefs.tempoRange = .nan;prefs.libraryFont = .infinity;prefs.crossfaderCurve = "invalid" + XCTAssertEqual(prefs.sanitized.tempoRange,16);XCTAssertEqual(prefs.sanitized.libraryFont,11);XCTAssertEqual(prefs.sanitized.crossfaderCurve,"Equal power") + } + @MainActor func testDJSettingsPersistenceLoadProtectionEQAndHotCueAutoplay() 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 first = Song(id:1,title:"First",audio:fixture.path,cdg:nil,rating:0,plays:0) + let second = Song(id:2,title:"Second",audio:fixture.path,cdg:nil,rating:0,plays:0) + let system = DJSystem(storageURL:root);system.master = 0 + defer { system.a.player.stop();system.b.player.stop() } + system.load(first,into:"A");system.a.player.toggle() + system.load(second,into:"A");XCTAssertNotNil(system.error);XCTAssertEqual(system.a.song?.id,1) + system.a.player.stopDeck();system.error = nil + system.settings.autoPlayOnLoad = true;system.settings.resetEQOnLoad = true + system.settings.tempoRange = 8;system.settings.crossfaderCurve = "Linear";system.settings.headroomDB = 6 + system.a.player.lowGain = 6;system.load(second,into:"A") + XCTAssertNil(system.error);XCTAssertEqual(system.a.song?.id,2);XCTAssertTrue(system.a.player.playing);XCTAssertEqual(system.a.player.lowGain,0) + system.a.player.stopDeck();system.settings.autoPlayOnLoad = false;system.settings.autoPlayHotCue = true + system.hotCue(0,deck:system.a);XCTAssertFalse(system.a.player.playing) + system.hotCue(0,deck:system.a);XCTAssertTrue(system.a.player.playing) + let restored = DJSystem(storageURL:root) + XCTAssertEqual(restored.settings,system.settings) + } +} diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 794ed41..d7750a5 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -8,10 +8,10 @@ The main window now opens with the original karaoke workspace above a resizable - 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. +- High/mid/low EQ and the bipolar filter are beside each platter. Gain and level knobs have been removed from the on-screen decks. Drag a knob vertically; double-click to reset. Knobs expose adjustable accessibility actions and current values. EQ uses the existing three-band filters; filter is low-pass left, bypass centre, high-pass right. - Play/pause, stop-to-start, set/return cue and four set/jump hot cues are per deck. Right-click a hot cue to clear it. Manual IN/OUT loops do not need BPM. Beat loops require confirmed BPM; choose 1–32 beats. - Enter source BPM (40–240) or tap at least three beats. Loading resets BPM to an unconfirmed placeholder. Sync matches the other deck’s effective tempo; it does not analyse BPM or align a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch. -- 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. +- The crossfader and DJ output headroom affect only DJ playback. Equal-power and linear crossfader curves are selectable. The karaoke master and saved singer preferences are independent. The wing meters show individual deck peaks, not a summed master meter. There is no master limiter; use gain/levels appropriately. - Karaoke and DJ music can play simultaneously. The existing background-to-singer interlock is preserved, but it does not automatically duck the new DJ decks. ## Library and playlists @@ -58,3 +58,13 @@ The three zero-byte files observed in the local DJ media directory were repaired Studio settings open as a dedicated modal from the toolbar, sidebar or Command-comma. Song Settings also opens as a titled modal with a Done action. The deprecated AVAsset metadata read in FileTagWriter now uses asynchronous loading. Verification after these changes: Debug build succeeds; **116 tests pass with zero failures**, including partial-copy repair, source permission preservation, failed-verification/cancellation cleanup and readable empty-audio errors. Native settings modal capture was inspected. + +## Continuous console and separate preferences + +The deck area is now one continuous console following the supplied reference: track headers and overview waveforms above; mirrored tempo/platter/EQ/filter wings; stacked, source-timed eight-second scrolling waveforms in the centre; and a shared bottom transport strip with a centred rectangular-cap crossfader. Native vertical tempo faders have visible scale marks, keyboard/accessibility support and a tested control-to-binding action. Play, Cue, loop size, headphones and hot cues remain available. No on-screen gain or channel-level knobs are shown. Cover tiles currently show a music-note placeholder; album artwork and key analysis are not added. + +Settings now use an icon-category toolbar with separate Karaoke and DJ selectors. DJ Settings opens directly in DJ scope; Audio Routing and MIDI open the corresponding DJ categories. Karaoke device routing applies only to the singer deck. DJ General, Sound, Library, Appearance and Advanced preferences are persisted independently in DJ/DJSettings.json. Working options include paused/automatic load, active-deck protection, reset EQ/filter on load, ±8/16/50% tempo range and inversion, equal-power/linear crossfade curves, output headroom, waveform colours/window/grid, rotating marker, inactive-deck dimming, library font/row height and optional hot-cue autoplay. MIDI learning is cancelled when leaving its settings category. + +No nonfunctional switches were added for DVS, booth/microphone routing, Neural Mix, automix, recording, Spotify or cloud sync. Sync remains entered/tapped BPM tempo matching within the chosen range; centre waveform grids mark time, not automatically detected beats. + +Validation: full suite **119 tests, zero failures**, including preference persistence, load protection, EQ reset, cue autoplay, tempo mapping and native fader orientation/actions. Native console and settings captures were visually inspected. Temporary builds, logs and repair tools were moved outside Developer to /private/tmp. diff --git a/work/repair-empty-dj-media.py b/work/repair-empty-dj-media.py deleted file mode 100644 index b2c4d4a..0000000 --- a/work/repair-empty-dj-media.py +++ /dev/null @@ -1,48 +0,0 @@ -import hashlib -import os -from pathlib import Path -import sqlite3 -import sys -import tempfile - -root = Path.home() / 'Library/Application Support/KJamsClone/DJ' -media = root / 'Media' -def digest(path): - h = hashlib.sha256() - with path.open('rb') as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b''): - h.update(chunk) - return h.hexdigest() -with sqlite3.connect(f'file:{root / "Library.sqlite"}?mode=ro', uri=True) as db: - originals = [Path(row[0]) for row in db.execute('select original_path from songs') if row[0]] -# Include the failed, unregistered source only within the same explicitly imported folder. -folders = {p.parent for p in originals} -sources = set(originals) -for folder in folders: - sources.update(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() == '.mp3') -repairs = [] -for source in sorted(sources): - if not source.is_file() or source.stat().st_size == 0: - continue - expected = digest(source) - target = media / (expected + source.suffix.lower()) - if target.is_file() and target.stat().st_size == 0: - repairs.append((source, target, expected)) -print(f'Verified {len(repairs)} empty DJ copies with matching original audio.') -if '--apply' in sys.argv: - for source, target, expected in repairs: - descriptor, temporary = tempfile.mkstemp(prefix='.kjmix-repair-', dir=media) - try: - with os.fdopen(descriptor, 'wb') as output, source.open('rb') as input_stream: - for chunk in iter(lambda: input_stream.read(1024 * 1024), b''): - output.write(chunk) - output.flush() - os.fsync(output.fileno()) - assert digest(Path(temporary)) == expected - assert target.stat().st_size == 0, 'Destination changed; refusing to replace it' - os.chmod(temporary, 0o644) - os.replace(temporary, target) - print(f'Repaired verified audio: {target.name} ({target.stat().st_size} bytes)') - finally: - if os.path.exists(temporary): - os.unlink(temporary)