broken changes

This commit is contained in:
Mark Gregory
2026-11-05 22:27:47 +00:00
parent d348aa76ca
commit df8cb6b17d
5 changed files with 192 additions and 22 deletions
+4
View File
@@ -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 = "<group>"; };
B50000000000000000000002 /* DJLoopEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJLoopEditing.swift; sourceTree = "<group>"; };
B40000000000000000000002 /* DJAutomix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAutomix.swift; sourceTree = "<group>"; };
B30000000000000000000002 /* DJAudioRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJAudioRouting.swift; sourceTree = "<group>"; };
@@ -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 */,
+146
View File
@@ -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<Content: View>: 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<DJNativeDropView>.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<AnyView> {
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) }
}
+28 -19
View File
@@ -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<AnyCancellable>()
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 {
+13 -2
View File
@@ -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
+1 -1
View File
@@ -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.