diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index 33889fa..ca9055b 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60000000000000000000002; }; B50000000000000000000001 /* DJLoopEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50000000000000000000002; }; B40000000000000000000001 /* DJAutomix.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40000000000000000000002; }; B30000000000000000000001 /* DJAudioRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002; }; @@ -39,6 +40,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + B60000000000000000000002 /* DJNativeDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJNativeDragDrop.swift; sourceTree = ""; }; B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = ""; }; B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = ""; }; B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = ""; }; @@ -82,6 +84,7 @@ B30000000000000000000002 /* DJAudioRouting.swift */, B40000000000000000000002 /* DJAutomix.swift */, B50000000000000000000002 /* DJLoopEditing.swift */, + B60000000000000000000002 /* DJNativeDragDrop.swift */, B20000000000000000000004 /* MIDIMappings */, 000000000000000000000002 /* Library.swift */, 000000000000000000000003 /* CDG.swift */, @@ -208,6 +211,7 @@ B30000000000000000000001 /* DJAudioRouting.swift in Sources */, B40000000000000000000001 /* DJAutomix.swift in Sources */, B50000000000000000000001 /* DJLoopEditing.swift in Sources */, + B60000000000000000000001 /* DJNativeDragDrop.swift in Sources */, 000000000000000000000066 /* Library.swift in Sources */, 000000000000000000000067 /* CDG.swift in Sources */, 000000000000000000000068 /* Importer.swift in Sources */, diff --git a/KJamsClone/DJNativeDragDrop.swift b/KJamsClone/DJNativeDragDrop.swift new file mode 100644 index 0000000..f89a1e6 --- /dev/null +++ b/KJamsClone/DJNativeDragDrop.swift @@ -0,0 +1,146 @@ +import SwiftUI +import AppKit + + +enum DJPasteboardPayload { + static func song(_ pasteboard: NSPasteboard) -> SongDragReference? { + guard let text = pasteboard.string(forType:.string),text.hasPrefix("KJMix.song.v1:"), + let data = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { return nil } + return try? JSONDecoder().decode(SongDragReference.self,from:data) + } + static func files(_ pasteboard: NSPasteboard) -> [URL] { + (pasteboard.readObjects(forClasses:[NSURL.self],options:[.urlReadingFileURLsOnly:true]) as? [URL] ?? []).filter(\.isFileURL) + } +} + +struct DJLibraryDragTitle: NSViewRepresentable { + let title: String + let reference: SongDragReference + let fontSize: Double + let select: () -> Void + func makeNSView(context: Context) -> DJLibraryDragView { DJLibraryDragView() } + func updateNSView(_ view: DJLibraryDragView,context: Context) { + view.title = title;view.reference = reference;view.fontSize = fontSize;view.select = select + view.toolTip = "Drag \(title) to either deck";view.setAccessibilityLabel(title);view.needsDisplay = true + } + func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJLibraryDragView,context: Context) -> CGSize? { .init(width:proposal.width ?? 240,height:24) } +} + +final class DJLibraryDragView: NSView { + var title = "" + var reference: SongDragReference? + var fontSize: Double = 11 + var select: (() -> Void)? + private var armed = false + private var dragging = false + override init(frame: NSRect) { super.init(frame:frame);setAccessibilityElement(true);setAccessibilityRole(.staticText) } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func draw(_ dirtyRect: NSRect) { + let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail + let font = NSFont.systemFont(ofSize:fontSize,weight:.medium) + let rect = NSRect(x:0,y:(bounds.height-ceil(font.ascender-font.descender))/2,width:bounds.width,height:ceil(font.ascender-font.descender)+2) + (title as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:NSColor.white,.paragraphStyle:paragraph]) + } + override func mouseDown(with event: NSEvent) { armed = true;dragging = false } + override func mouseDragged(with event: NSEvent) { + guard armed else { return } + dragging = true + NSCursor.closedHand.set() + for target in DJNativeDropView.targets.allObjects where target.window === window { + let inside = !target.isHiddenOrHasHiddenAncestor && target.bounds.contains(target.convert(event.locationInWindow,from:nil)) + target.layer?.borderWidth = inside ? 2 : 0 + target.layer?.borderColor = target.tint.cgColor + } + } + override func mouseUp(with event: NSEvent) { + defer { + armed = false;dragging = false;NSCursor.arrow.set() + for target in DJNativeDropView.targets.allObjects { target.layer?.borderWidth = 0 } + } + guard dragging,let reference,let window else { select?();return } + for target in DJNativeDropView.targets.allObjects where target.window === window && !target.isHiddenOrHasHiddenAncestor { + if target.bounds.contains(target.convert(event.locationInWindow,from:nil)),target.validSong?(reference) == true { + let receive = target.acceptSong + DispatchQueue.main.async { receive?(reference) } + break + } + } + } + +} + +struct DJDeckDropZone: NSViewRepresentable { + let system: DJSystem + let letter: String + let content: Content + init(system: DJSystem,letter: String,@ViewBuilder content: () -> Content) { self.system = system;self.letter = letter;self.content = content() } + func makeNSView(context: Context) -> DJNativeDropView { + let view = DJNativeDropView();updateNSView(view,context:context);return view + } + func updateNSView(_ view: DJNativeDropView,context: Context) { + view.host.rootView = AnyView(content.environment(\.colorScheme,context.environment.colorScheme)) + view.tint = letter == "A" ? .systemCyan : .systemOrange + view.acceptSong = { [weak system] reference in + guard let system,reference.library == system.dragSession,let song = system.songs.first(where:{$0.id == reference.song}) else { return } + if letter == "Queue" { system.automix.enqueue([song]) } else { system.load(song,into:letter) } + } + view.acceptFiles = letter == "Queue" ? nil : { [weak system] urls in system?.requestFileLoad(urls,into:letter) } + view.validSong = { [weak system] reference in reference.library == system?.dragSession } + } + func sizeThatFits(_ proposal: ProposedViewSize,nsView: DJNativeDropView,context: Context) -> CGSize? { + .init(width:proposal.width ?? nsView.host.fittingSize.width,height:proposal.height ?? nsView.host.fittingSize.height) + } +} + +final class DJNativeDropView: NSView { + static let targets = NSHashTable.weakObjects() + let host = DJDropHostingView(rootView:AnyView(EmptyView())) + var tint = NSColor.systemCyan + var acceptSong: ((SongDragReference) -> Void)? + var acceptFiles: (([URL]) -> Void)? + var validSong: ((SongDragReference) -> Bool)? + override init(frame: NSRect) { + super.init(frame:frame);Self.targets.add(self);registerForDraggedTypes([.string,.fileURL]);wantsLayer = true + host.dropTarget = self;host.registerForDraggedTypes([.string,.fileURL]) + host.translatesAutoresizingMaskIntoConstraints = false;addSubview(host) + NSLayoutConstraint.activate([host.leadingAnchor.constraint(equalTo:leadingAnchor),host.trailingAnchor.constraint(equalTo:trailingAnchor),host.topAnchor.constraint(equalTo:topAnchor),host.bottomAnchor.constraint(equalTo:bottomAnchor)]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func accepts(_ pasteboard: NSPasteboard) -> Bool { + if let song = DJPasteboardPayload.song(pasteboard) { return validSong?(song) == true } + return !DJPasteboardPayload.files(pasteboard).isEmpty + } + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let allowed = accepts(sender.draggingPasteboard) + layer?.borderWidth = allowed ? 2 : 0;layer?.borderColor = tint.cgColor + return allowed ? .copy : [] + } + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { accepts(sender.draggingPasteboard) ? .copy : [] } + override func draggingExited(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 } + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { accepts(sender.draggingPasteboard) } + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + layer?.borderWidth = 0 + if let song = DJPasteboardPayload.song(sender.draggingPasteboard),validSong?(song) == true { + let receive = acceptSong + // Finish the native dragging session before a load rebuilds any SwiftUI rows. + DispatchQueue.main.async { receive?(song) };return true + } + let files = DJPasteboardPayload.files(sender.draggingPasteboard) + guard !files.isEmpty else { return false } + let receive = acceptFiles;DispatchQueue.main.async { receive?(files) };return true + } + override func concludeDragOperation(_ sender: NSDraggingInfo?) { layer?.borderWidth = 0 } +} + +// NSHostingView participates in AppKit drag destination lookup itself. Forward +// its callbacks so embedded SwiftUI content cannot shadow the deck destination. +final class DJDropHostingView: NSHostingView { + weak var dropTarget: DJNativeDropView? + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingEntered(sender) ?? [] } + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { dropTarget?.draggingUpdated(sender) ?? [] } + override func draggingExited(_ sender: NSDraggingInfo?) { dropTarget?.draggingExited(sender) } + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.prepareForDragOperation(sender) ?? false } + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { dropTarget?.performDragOperation(sender) ?? false } + override func concludeDragOperation(_ sender: NSDraggingInfo?) { dropTarget?.concludeDragOperation(sender) } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index af8f775..0d6331e 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -158,6 +158,7 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var crossfader: Double = 0.5 { didSet { applyMix() } } @Published var master: Double = 0.8 { didSet { applyMix() } } @Published private(set) var syncActive = false + private(set) var syncLeader = "A" private var syncOriginal: (Double,Double)? private var syncObservers = Set() private var updatingSync = false @@ -335,11 +336,12 @@ struct DJAudioDevice: Identifiable, Equatable { 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 + let follower = deck.letter == "A" ? b : a + let target = deck.bpm*deck.player.tempo,rate = target/follower.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) + syncLeader = deck.letter;syncActive = true;updatingSync = true;follower.player.tempo = rate;updatingSync = false + status = String(format:"SYNC ON · Both decks %.1f BPM · %@ is master",target,deck.letter) } func disableSync() { guard syncActive else { return };syncActive = false @@ -348,9 +350,17 @@ struct DJAudioDevice: Identifiable, Equatable { } private func maintainSync() { guard syncActive,!updatingSync,a.bpmConfirmed,b.bpmConfirmed else { return } - let rate = a.bpm*a.player.tempo/b.bpm + let leader = channel(syncLeader),follower = syncLeader == "A" ? b : a + let rate = leader.bpm*leader.player.tempo/follower.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 } + if abs(follower.player.tempo-rate) > 0.00001 { updatingSync = true;follower.player.tempo = rate;updatingSync = false } + } + private func setDeckTempo(_ rate: Double,deck: DJChannel) { + if syncActive,deck.letter != syncLeader { + let leader = channel(syncLeader) + leader.player.tempo = deck.bpm*rate/leader.bpm + maintainSync() + } else { deck.player.tempo = rate } } func toggleLoop(_ deck: DJChannel) { guard deck.player.canJog else { status = "Load a track first.";return } @@ -389,7 +399,7 @@ struct DJAudioDevice: Identifiable, Equatable { d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor))) case "Tempo XML": let rate = 1+(value*2-1)*settings.tempoRange/100 - if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate } + setDeckTempo(rate,deck:d) case "Loop 1","Loop 2","Loop 4","Loop 8","Loop 16","Loop 32": let beats = Int(parts[1].split(separator:" ").last!) ?? 8 if p.loopEnabled,d.loopBeats != beats { d.loopBeats = beats;status = "Deck \(d.letter) · \(beats)-beat loop active" } @@ -405,7 +415,7 @@ 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": 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 "Tempo": setDeckTempo(settings.tempo(at:value),deck:d) 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) @@ -712,7 +722,7 @@ struct DJConsole: View { DJContinuousConsole(system:system,loadA:{loadSelected("A")},loadB:{loadSelected("B")}).fixedSize(horizontal:false,vertical:true) HStack(alignment:.top,spacing:12) { browser.frame(maxWidth:.infinity,maxHeight:.infinity) - DJQueuePane(system:system,automix:system.automix).frame(width:310).frame(maxHeight:.infinity) + DJDeckDropZone(system:system,letter:"Queue") { DJQueuePane(system:system,automix:system.automix) }.frame(width:310).frame(maxHeight:.infinity) }.frame(maxWidth:.infinity,maxHeight:.infinity) if let progress = system.importProgress { HStack { @@ -772,14 +782,13 @@ struct DJConsole: View { 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) + DJLibraryDragTitle(title:song.title,reference:system.dragReference(song.id),fontSize:system.settings.libraryFont,select:{selected = song.id}).frame(maxWidth:.infinity,alignment:.leading) Text(song.artist).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).frame(width:130,alignment:.leading) Text(DJStyle.stamp(song.duration)).font(.system(size:10).monospacedDigit()).foregroundStyle(.secondary).frame(width:35) Button { system.automix.enqueue([song]) } label: { Image(systemName:"plus") }.accessibilityLabel("Add \(song.title) to DJ queue") Button("A") { system.load(song,into:"A") }.tint(DJStyle.a).accessibilityLabel("Load \(song.title) into A") Button("B") { system.load(song,into:"B") }.tint(DJStyle.b).accessibilityLabel("Load \(song.title) into B") }.padding(.vertical,max(0,(system.settings.libraryRowHeight-24)/2)).tag(song.id).buttonStyle(.bordered).controlSize(.mini) - .onDrag { system.dragReference(song.id).provider() } .contextMenu { playlistMenu(song) } } if visible.isEmpty { Text("Import music or choose another playlist.").font(.caption).foregroundStyle(.secondary) } @@ -852,7 +861,7 @@ private struct DJDeckSurfaces { 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") + Button("SYNC") { system.sync(deck) }.foregroundStyle(system.syncActive ? color : Color.secondary).font(.system(size:11,weight:.bold)) .buttonStyle(DJConsoleButtonStyle(system.syncActive ? color : nil,active:system.syncActive)).accessibilityValue(system.syncActive ? "On" : "Off").help("Match the other deck to this deck’s BPM; click again to restore both 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) @@ -1084,20 +1093,20 @@ struct DJContinuousConsole: View { var body: some View { VStack(spacing:0) { HStack(spacing:0) { - DJTrackHeader(deck:system.a,player:system.a.player,system:system,load:loadA) + DJDeckDropZone(system:system,letter:"A") { 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) + DJDeckDropZone(system:system,letter:"B") { 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) + DJDeckDropZone(system:system,letter:"A") { DJConsoleWing(deck:system.a,player:system.a.player,system:system) }.frame(width:322) Divider() VStack(spacing:1) { - DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"A") } - DJCentreWaveform(deck:system.b,player:system.b.player,letter:"B",color:DJStyle.b,settings:system.settings).contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:"B") } + DJDeckDropZone(system:system,letter:"A") { DJCentreWaveform(deck:system.a,player:system.a.player,letter:"A",color:DJStyle.a,settings:system.settings) } + DJDeckDropZone(system:system,letter:"B") { 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) + DJDeckDropZone(system:system,letter:"B") { DJConsoleWing(deck:system.b,player:system.b.player,system:system) }.frame(width:322) }.frame(height:198).background(Color(white:0.12)) Divider() HStack(spacing:12) { @@ -1139,7 +1148,7 @@ private struct DJTrackHeader: View { } if deck.letter == "B" { artwork } }.padding(10).frame(maxWidth:.infinity) - .contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } + } private var artwork: some View { ZStack { @@ -1171,7 +1180,7 @@ private struct DJConsoleWing: View { 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) - .contentShape(Rectangle()).onDrop(of:[UTType.fileURL.identifier,"public.text"],isTargeted:nil) { system.acceptDrop($0,into:deck.letter) } + } } private struct DJConsoleTransport: View { diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 27735c9..96720b1 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -1940,7 +1940,7 @@ extension KJamsCloneTests { } extension KJamsCloneTests { - @MainActor func testLinkedSyncUsesFirstDeckTogglesBothAndRestoresOriginalTempos() async throws { + @MainActor func testLinkedSyncUsesClickedDeckTogglesBothAndRestoresOriginalTempos() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let system = DJSystem(storageURL:root);system.master = 0 @@ -1950,13 +1950,24 @@ extension KJamsCloneTests { system.load(song,into:"A");system.load(song,into:"B") system.a.bpm = 128;system.b.bpm = 120;system.a.bpmConfirmed = true;system.b.bpmConfirmed = true system.a.player.tempo = 1.04;system.b.player.tempo = 0.96 - system.sync(system.b) + XCTAssertFalse(system.syncActive) + system.sync(system.a) XCTAssertTrue(system.syncActive);XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001) XCTAssertEqual(system.b.bpm*system.b.player.tempo,128*1.04,accuracy:0.001) system.a.player.tempo = 1.02;try await Task.sleep(nanoseconds:40_000_000) XCTAssertEqual(system.b.bpm*system.b.player.tempo,128*1.02,accuracy:0.001) system.sync(system.a) XCTAssertFalse(system.syncActive);XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001);XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001) + system.sync(system.b) + XCTAssertTrue(system.syncActive);XCTAssertEqual(system.syncLeader,"B") + XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001) + XCTAssertEqual(system.a.bpm*system.a.player.tempo,120*0.96,accuracy:0.001) + system.b.player.tempo = 1.01;try await Task.sleep(nanoseconds:40_000_000) + XCTAssertEqual(system.a.bpm*system.a.player.tempo,120*1.01,accuracy:0.001) + system.sync(system.b) + XCTAssertFalse(system.syncActive) + XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001) + XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001) } @MainActor func testDJLoopActuallyRepeatsAndExplicitCueDoesNotMovePlayback() async throws { let deck = DJChannel("A");defer { deck.unload() };deck.master = 0 diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 507c96b..457cf88 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -10,7 +10,7 @@ The main window has two tabs: **Karaoke** and **DJ Studio**. Playback, MIDI hand - Waveforms come from decoded audio and reuse existing waveform caches. Drag outside an active loop to seek. Green IN/OUT grips resize the loop; dragging inside its region moves it without changing its length. Shift-drag creates a loop in either waveform. The scrolling viewport freezes during a drag and resumes following playback afterwards. - High/mid/low EQ and the bipolar filter are beside each platter. Gain and level knobs have been removed from the on-screen decks. Drag a knob vertically; double-click to reset. Knobs expose adjustable accessibility actions and current values. EQ uses the existing three-band filters; filter is low-pass left, bypass centre, high-pass right. - Play/pause, stop-to-start, set/return cue and four set/jump hot cues are per deck. Right-click a hot cue to clear it. Use the cue-time editor to set an exact cue in seconds or capture the current playhead without moving playback. Loop ✎ opens exact start/end fields with millisecond display and Use Playhead buttons; Activate Loop validates and applies the region without pausing playback. Manual IN/OUT loops do not need BPM. Beat loops require an estimated, entered or tapped BPM; choose 1–32 beats. Loop toggles off on a second press and continues correctly at the end of a track. Changing the loop size resizes an active loop immediately, preserving its start and continuing playback; the end is clamped to track duration. -- Loading estimates BPM conservatively from up to the first 90 seconds of audio, searching 70–180 BPM; silence and steady tones remain unknown. Estimates show BPM~ and can be replaced by entering source BPM (40–240) or tapping at least three beats. Either Sync button links both decks to deck A’s effective BPM and both buttons show active. Press either again to unlink and restore both original tempos. Sync follows changes to A’s tempo; it does not align beat phase or detect a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch. +- Loading estimates BPM conservatively from up to the first 90 seconds of audio, searching 70–180 BPM; silence and steady tones remain unknown. Estimates show BPM~ and can be replaced by entering source BPM (40–240) or tapping at least three beats. Sync buttons start grey and unlit. Clicking A makes B follow A’s effective BPM; clicking B makes A follow B’s effective BPM. Both buttons then show active. Press either again to unlink and restore both original tempos. Sync follows changes to the clicked leader’s tempo; it does not align beat phase or detect a beat grid. Use the jog/nudge buttons to align beats. Tempo adjustment preserves pitch, using AVAudioUnitTimePitch. - The crossfader and DJ output headroom affect only DJ playback. Equal-power and linear crossfader curves are selectable. The karaoke master and saved singer preferences are independent. The wing meters show individual deck peaks, not a summed master meter. There is no master limiter; use gain/levels appropriately. - Karaoke and DJ music can play simultaneously. The existing background-to-singer interlock is preserved, but it does not automatically duck the new DJ decks.