From 78c4411fa66ae1f8d942b0a72ee070f4c7565b81 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 5 Nov 2026 21:10:04 +0000 Subject: [PATCH] broken changes --- KJMix Pro.xcodeproj/project.pbxproj | 4 + KJamsClone/DJLoopEditing.swift | 103 ++++++++++++++++++++++++++ KJamsClone/DJWorkstation.swift | 25 ++++--- KJamsClone/Player.swift | 40 +++++++--- KJamsCloneTests/KJamsCloneTests.swift | 57 ++++++++++++++ docs/DJ_WORKSTATION.md | 10 ++- 6 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 KJamsClone/DJLoopEditing.swift diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj index fd9ae1d..33889fa 100644 --- a/KJMix Pro.xcodeproj/project.pbxproj +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 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; }; B20000000000000000000001 /* DJXMLMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002; }; @@ -38,6 +39,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 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 = ""; }; B20000000000000000000002 /* DJXMLMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJXMLMapping.swift; sourceTree = ""; }; @@ -79,6 +81,7 @@ B20000000000000000000002 /* DJXMLMapping.swift */, B30000000000000000000002 /* DJAudioRouting.swift */, B40000000000000000000002 /* DJAutomix.swift */, + B50000000000000000000002 /* DJLoopEditing.swift */, B20000000000000000000004 /* MIDIMappings */, 000000000000000000000002 /* Library.swift */, 000000000000000000000003 /* CDG.swift */, @@ -204,6 +207,7 @@ B20000000000000000000001 /* DJXMLMapping.swift in Sources */, B30000000000000000000001 /* DJAudioRouting.swift in Sources */, B40000000000000000000001 /* DJAutomix.swift in Sources */, + B50000000000000000000001 /* DJLoopEditing.swift in Sources */, 000000000000000000000066 /* Library.swift in Sources */, 000000000000000000000067 /* CDG.swift in Sources */, 000000000000000000000068 /* Importer.swift in Sources */, diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift new file mode 100644 index 0000000..205c0ff --- /dev/null +++ b/KJamsClone/DJLoopEditing.swift @@ -0,0 +1,103 @@ +import SwiftUI +import AppKit + +struct DJLoopDrag { + enum Mode { case seek, start, end, move, create } + let mode: Mode + let viewport: Double + let secondsPerPixel: Double + let anchor: Double + let original: ClosedRange? + init(x: Double,viewport: Double,span: Double,width: Double,loop: ClosedRange?,create: Bool) { + self.viewport = viewport;secondsPerPixel = span/max(1,width);anchor = viewport+x*secondsPerPixel;original = loop + if create { mode = .create } + else if let loop { + let left = abs(anchor-loop.lowerBound),right = abs(anchor-loop.upperBound),tolerance = 8*secondsPerPixel + if min(left,right) <= tolerance { mode = left <= right ? .start : .end } + else if loop.contains(anchor) { mode = .move } + else { mode = .seek } + } else { mode = .seek } + } + func range(x: Double,duration: Double) -> ClosedRange? { + guard duration >= 0.1,secondsPerPixel > 0 else { return nil } + let point = min(duration,max(0,viewport+x*secondsPerPixel)) + switch mode { + case .seek: return nil + case .create: + let first = min(duration,max(0,anchor)),start = min(duration-0.1,min(first,point)) + return start...min(duration,max(start+0.1,max(first,point))) + case .start: + guard let original else { return nil };return min(point,original.upperBound-0.1)...original.upperBound + case .end: + guard let original else { return nil };return original.lowerBound...max(original.lowerBound+0.1,point) + case .move: + guard let original else { return nil } + let length = original.upperBound-original.lowerBound + let start = min(duration-length,max(0,original.lowerBound+(viewport+x*secondsPerPixel-anchor))) + return start...(start+length) + } + } +} + +struct DJLoopWaveInteraction: ViewModifier { + @ObservedObject var player: Player + let start: Double + let span: Double + let width: Double + @Binding var frozenStart: Double? + @State private var drag: DJLoopDrag? + func body(content: Content) -> some View { + content.contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance:0).onChanged { value in + guard player.canJog,span > 0 else { return } + if drag == nil { + let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil + drag = DJLoopDrag(x:value.startLocation.x,viewport:start,span:span,width:width,loop:loop,create:NSEvent.modifierFlags.contains(.shift)) + frozenStart = start + } + guard let drag else { return } + if let range = drag.range(x:value.location.x,duration:player.duration) { player.defineLoop(start:range.lowerBound,end:range.upperBound) } + else if drag.mode == .seek { player.seek(drag.viewport+value.location.x*drag.secondsPerPixel) } + }.onEnded { _ in drag = nil;frozenStart = nil }) + .onChange(of:player.currentSongID) { _ in drag = nil;frozenStart = nil } + .onContinuousHover { phase in + switch phase { + case .active(let point): + let loop = player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil + let mode = DJLoopDrag(x:point.x,viewport:frozenStart ?? start,span:span,width:width,loop:loop,create:NSEvent.modifierFlags.contains(.shift)).mode + switch mode { + case .start,.end: NSCursor.resizeLeftRight.set() + case .move: NSCursor.openHand.set() + case .create: NSCursor.crosshair.set() + case .seek: NSCursor.arrow.set() + } + case .ended: NSCursor.arrow.set() + } + } + .help("Drag green IN/OUT edges to resize; drag inside the loop to move it. Shift-drag to define a new loop. Drag outside it to seek.") + } +} + +struct DJLoopEditor: View { + @ObservedObject var player: Player + let letter: String + @State private var start: Double + @State private var end: Double + @Environment(\.dismiss) private var dismiss + init(player: Player,letter: String) { + self.player = player;self.letter = letter + let first = player.loopIn ?? min(max(0,player.time),max(0,player.duration-0.1)) + _start = State(initialValue:first);_end = State(initialValue:player.loopOut ?? min(player.duration,first+4)) + } + private var valid: Bool { start.isFinite && end.isFinite && start >= 0 && end <= player.duration && end-start >= 0.1-1e-9 } + var body: some View { + VStack(alignment:.leading,spacing:18) { + Text("Deck \(letter) · Loop Points").font(.title2.bold()) + HStack { Text("Start (seconds)").frame(width:120,alignment:.leading);TextField("Start",value:$start,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { start = player.time } } + HStack { Text("End (seconds)").frame(width:120,alignment:.leading);TextField("End",value:$end,format:.number.precision(.fractionLength(3))).textFieldStyle(.roundedBorder);Button("Use Playhead") { end = player.time } } + Text(valid ? String(format:"Loop length: %.3f seconds",end-start) : "End must be at least 0.1 seconds after start, within the track.").font(.caption).foregroundStyle(valid ? Color.secondary : Color.orange) + Text("On either waveform, drag the green edges to resize or the loop region to move. Shift-drag defines a new loop. Playback continues while editing.").font(.callout).foregroundStyle(.secondary) + HStack { Button("Cancel") { dismiss() };Spacer();Button("Activate Loop") { player.defineLoop(start:start,end:end);dismiss() }.disabled(!valid || !player.canJog).keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:540) + } +} diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index 4a6ff68..e10d484 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -165,6 +165,7 @@ struct DJAudioDevice: Identifiable, Equatable { @Published var cueEditor: String? @Published var cueDraft: Double = 0 @Published var cueEditing = false + @Published var loopEditor: String? @Published var cueLevel: Double = 0.6 { didSet { preview.volume = cueLevel } } @Published private(set) var previewDeck: String? @Published var devices = [DJAudioDevice]() @@ -261,9 +262,7 @@ struct DJAudioDevice: Identifiable, Equatable { if previewDeck == letter { stopPreview() } try deck.load(song) let route = routing.route(letter) - if !route.uid.isEmpty || route.firstChannel != 0 || routing.mode == "External" { - do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error } - } + do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error } if settings.resetEQOnLoad { deck.player.resetTone() } if settings.autoPlayOnLoad && !automated { deck.player.toggle() } status = "\(song.title) ready on \(letter) · set or tap BPM for tempo sync and beat loops" @@ -287,14 +286,18 @@ struct DJAudioDevice: Identifiable, Equatable { let previous = routing perform { try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput()) - automix.manualTakeover();stopPreview() + automix.manualTakeover() + let previewActive = previewDeck != nil do { for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } + if previewActive,!next.cue.uid.isEmpty { try preview.routeOutput(try device(next.cue.uid),firstChannel:next.cue.firstChannel) } try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic) routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix() + if next.cue.uid.isEmpty { stopPreview() } status = "Audio routing applied · \(next.mode) mixer" } catch { for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } } + if previewActive,!previous.cue.uid.isEmpty,let output = try? device(previous.cue.uid) { try? preview.routeOutput(output,firstChannel:previous.cue.firstChannel) } throw error } } @@ -734,6 +737,7 @@ struct DJConsole: View { Button("Replace & Load",role:.destructive) { system.confirmReplacement(request) } } message: { request in Text("Deck \(request.deck) is playing. Replacing it with \(request.title) stops the current track. Continue?") } .sheet(isPresented:$system.cueEditing) { DJCueEditor(system:system) } + .sheet(isPresented:Binding(get:{system.loopEditor != nil},set:{if !$0 { system.loopEditor = nil }})) { let deck = system.channel(system.loopEditor ?? "A");DJLoopEditor(player:deck.player,letter:deck.letter) } .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") } @@ -940,6 +944,7 @@ private struct DJPlatter: View { } } private struct DJWaveform: View { + @State private var frozenStart: Double? @ObservedObject var player: Player let color: Color var bpm: Double? @@ -959,7 +964,7 @@ private struct DJWaveform: View { context.fill(Path(CGRect(x:x,y:0,width:1.5,height:size.height)),with:.color(.white)) }.background(.black.opacity(0.3),in:RoundedRectangle(cornerRadius:3)) .overlay { if player.waveformLoading { ProgressView().controlSize(.mini) } else if player.waveformUnavailable { Text("Waveform unavailable").font(.caption2).foregroundStyle(.secondary) } } - .contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in player.seek(Double(value.location.x/max(1,geometry.size.width))*player.duration) }) + .modifier(DJLoopWaveInteraction(player:player,start:0,span:player.duration,width:geometry.size.width,frozenStart:$frozenStart)) }.accessibilityLabel("Track waveform and position").accessibilityValue(DJStyle.stamp(player.time)).accessibilityAdjustableAction { player.jog($0 == .increment ? 1 : -1) } } } @@ -1207,7 +1212,7 @@ private struct DJConsoleTransport: View { 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() } + Button("Cue \(DJStyle.stamp(player.cuePosition)) ✎") { system.editCue(deck) };Button("IN") { player.markLoopIn() };Button("OUT") { player.markLoopOut() };Button("Loop ✎") { system.loopEditor = deck.letter } 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) @@ -1221,12 +1226,12 @@ private struct DJCentreWaveform: View { let color: Color private var bpm: Double? { deck.bpmConfirmed ? deck.bpm : nil } var settings = DJPreferences() - @State private var scrubStart: Double? + @State private var frozenStart: 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)) + let span = settings.waveformSeconds,start = frozenStart ?? (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))) @@ -1246,7 +1251,7 @@ private struct DJCentreWaveform: View { DJWaveMarkers.draw(context:&context,size:size,start:start,span:span,bpm:bpm,loop:player.loopEnabled ? player.loopIn.flatMap { a in player.loopOut.map { a...$0 } } : nil) context.fill(Path(CGRect(x:size.width/2,y:0,width:1.5,height:size.height)),with:.color(.red)) }.overlay(alignment:.topLeading) { Text(letter+" · \(Int(settings.waveformSeconds)) s").font(.system(size:9,weight:.semibold)).foregroundStyle(color).padding(5) } - .contentShape(Rectangle()).gesture(DragGesture(minimumDistance:0).onChanged { value in if scrubStart == nil { scrubStart = player.time-settings.waveformSeconds/2 };player.seek((scrubStart ?? 0)+Double(value.location.x/max(1,geometry.size.width))*settings.waveformSeconds) }.onEnded { _ in scrubStart = nil }) + .modifier(DJLoopWaveInteraction(player:player,start:player.time-settings.waveformSeconds/2,span:settings.waveformSeconds,width:geometry.size.width,frozenStart:$frozenStart)) }.accessibilityLabel("Deck \(letter) scrolling waveform").accessibilityValue(DJStyle.stamp(player.time)) } } @@ -1620,6 +1625,8 @@ enum DJWaveMarkers { let x = (time-start)/span*size.width if x >= 0,x <= size.width { context.fill(Path(CGRect(x:x,y:0,width:2,height:size.height)),with:.color(.green)) + context.fill(Path(CGRect(x:x-3,y:size.height/2-7,width:7,height:14)),with:.color(.green)) + context.fill(Path(CGRect(x:x,y:size.height/2-5,width:1,height:10)),with:.color(.black)) context.draw(Text(label).font(.system(size:8,weight:.bold)).foregroundColor(.green),at:CGPoint(x:min(size.width-12,max(12,x)),y:7)) } } diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift index 3a77b5f..1cc4a61 100644 --- a/KJamsClone/Player.swift +++ b/KJamsClone/Player.swift @@ -50,7 +50,7 @@ import CoreAudio 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 } + guard canJog,start.isFinite,end.isFinite,start >= 0,end <= duration,end-start >= 0.1-1e-9 else { return } loopIn = start;loopOut = end;loopEnabled = true } func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil } @@ -94,13 +94,35 @@ import CoreAudio private var persistsMix = true func routeOutput(_ device: AudioDeviceID,firstChannel: Int = 0) throws { guard let unit = engine.outputNode.audioUnit else { throw LibraryError.message("Audio output unavailable") } - let running = engine.isRunning;engine.stop();var value = device - let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value))) - guard result == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio routing failed (\(result))") } var map = try DJAudioRouting.channelMap(channels:DJAudioDevice.channelCount(device),first:firstChannel) - let mapResult = map.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) } - guard mapResult == noErr else { if running { try? engine.start() };throw LibraryError.message("Audio channel routing failed (\(mapResult))") } - if running { try engine.start() } + var previousDevice = AudioDeviceID(0),bytes = UInt32(MemoryLayout.size) + _ = AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,&bytes) + func setMap(_ values: inout [Int32]) -> OSStatus { + values.withUnsafeMutableBytes { AudioUnitSetProperty(unit,kAudioOutputUnitProperty_ChannelMap,kAudioUnitScope_Input,0,$0.baseAddress!,UInt32($0.count)) } + } + // Channel-only changes can be applied by HAL without interrupting the player. + if previousDevice == device,setMap(&map) == noErr { return } + tick() + let running = engine.isRunning,resume = playing,position = time,wasWaiting = waitingForBackground + var previousMap = outputChannelMap() + cancelPendingPlayback();generation = UUID();node.stop();engine.stop() + do { + var value = device + let result = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&value,UInt32(MemoryLayout.size(ofValue:value))) + guard result == noErr else { throw LibraryError.message("Audio routing failed (\(result))") } + let mapResult = setMap(&map) + guard mapResult == noErr else { throw LibraryError.message("Audio channel routing failed (\(mapResult))") } + if running || resume { try engine.start() } + if file != nil { schedule(at:position,play:resume,notifyPlayback:wasWaiting) } + } catch { + if previousDevice != 0 { + _ = AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&previousDevice,UInt32(MemoryLayout.size)) + if !previousMap.isEmpty { _ = setMap(&previousMap) } + } + if running || resume { try? engine.start() } + if file != nil { schedule(at:position,play:resume && engine.isRunning,notifyPlayback:wasWaiting) } + throw error + } } func outputChannelMap() -> [Int32] { guard let unit = engine.outputNode.audioUnit else { return [] } @@ -248,7 +270,7 @@ import CoreAudio self.onPlaybackReady?();self.node.play() } } - private func schedule(at seconds: Double, play: Bool) { + private func schedule(at seconds: Double, play: Bool,notifyPlayback: Bool = true) { guard let file else { return } cancelPendingPlayback();generation = UUID(); let token = generation node.stop();if !fading { setFade(1) } @@ -268,7 +290,7 @@ import CoreAudio } } } - if play { startNodePlayback() }; playing = play; render() + if play { if notifyPlayback { startNodePlayback() } else { node.play() } }; playing = play; render() } private func startTimer() { timer?.invalidate() diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index e487c5b..dc2468f 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2140,6 +2140,21 @@ extension KJamsCloneTests { control.selectItem(withTag:0);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target) XCTAssertEqual(selected,0) } + @MainActor func testLiveAudioRoutingKeepsPlayingPositionAndPausedState() async throws { + let output = try XCTUnwrap(DJAudioDevice.defaultOutput()) + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let player = Player(persistsMix:false);player.volume = 0;defer { player.stop() } + try player.load(.init(id:1,title:"Routing",audio:audio.path,cdg:nil,rating:0,plays:0)) + player.seek(0.5);try await Task.sleep(nanoseconds:100_000_000) + let before = player.time + try player.routeOutput(output,firstChannel:0) + XCTAssertTrue(player.playing);XCTAssertGreaterThanOrEqual(player.time,before-0.05) + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertGreaterThan(player.time,before) + player.toggle();let paused = player.time + try player.routeOutput(output,firstChannel:0) + XCTAssertFalse(player.playing);XCTAssertEqual(player.time,paused,accuracy:0.02) + } @MainActor func testAudioOutputsAndActualFourChannelRoutingWhenInterfaceAttached() async throws { let outputs = DJAudioDevice.outputs() print("DJ audio discovery:",outputs.map { "\($0.name) [\($0.outputChannels) out / \($0.inputChannels) in]" }.joined(separator:", ")) @@ -2328,6 +2343,48 @@ extension KJamsCloneTests { } extension KJamsCloneTests { + func testLoopMouseRangesResizeMoveCreateAndClamp() throws { + let loop = 1.0...3.0 + let left = DJLoopDrag(x:100,viewport:0,span:6,width:600,loop:loop,create:false) + XCTAssertEqual(left.mode,.start);XCTAssertEqual(left.range(x:50,duration:6),0.5...3) + let right = DJLoopDrag(x:300,viewport:0,span:6,width:600,loop:loop,create:false) + XCTAssertEqual(right.mode,.end);XCTAssertEqual(right.range(x:450,duration:6),1...4.5) + let move = DJLoopDrag(x:200,viewport:0,span:6,width:600,loop:loop,create:false) + XCTAssertEqual(move.mode,.move);XCTAssertEqual(move.range(x:300,duration:6),2...4) + XCTAssertEqual(move.range(x:-200,duration:6),0...2) + XCTAssertEqual(move.range(x:900,duration:6),4...6) + let create = DJLoopDrag(x:400,viewport:0,span:6,width:600,loop:nil,create:true) + XCTAssertEqual(create.range(x:200,duration:6),2...4) + let seek = DJLoopDrag(x:500,viewport:0,span:6,width:600,loop:loop,create:false) + XCTAssertEqual(seek.mode,.seek);XCTAssertNil(seek.range(x:400,duration:6)) + let narrow = try XCTUnwrap(left.range(x:500,duration:6)) + XCTAssertEqual(narrow.upperBound-narrow.lowerBound,0.1,accuracy:0.00001) + } + @MainActor func testActualMouseDragMovesAndResizesPlayingLoop() async throws { + let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let player = Player(persistsMix:false);player.volume = 0;defer { player.stop() } + try player.load(.init(id:1,title:"Mouse Loop",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false) + player.defineLoop(start:0.5,end:1.5);player.seek(0.75);player.toggle() + var frozen: Double? + let host = NSHostingView(rootView:Color.black.modifier(DJLoopWaveInteraction(player:player,start:0,span:3,width:600,frozenStart:Binding(get:{frozen},set:{frozen = $0}))).frame(width:600,height:100)) + host.frame = NSRect(x:0,y:0,width:600,height:100) + 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);window.makeKey() + defer { window.orderOut(nil);window.contentView = nil } + try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded() + func drag(_ from: Double,_ to: Double) async throws { + for (type,x) in [(NSEvent.EventType.leftMouseDown,from),(.leftMouseDragged,to),(.leftMouseUp,to)] { + let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:50),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseUp ? 0 : 1)) + NSApp.sendEvent(event) + } + try await Task.sleep(nanoseconds:50_000_000) + } + try await drag(200,300) + XCTAssertEqual(player.loopIn!,1,accuracy:0.01);XCTAssertEqual(player.loopOut!,2,accuracy:0.01) + try await drag(200,150) + XCTAssertEqual(player.loopIn!,0.75,accuracy:0.01);XCTAssertEqual(player.loopOut!,2,accuracy:0.01) + XCTAssertTrue(player.playing);XCTAssertTrue(player.loopEnabled);XCTAssertNil(frozen) + } @MainActor private func automixFixture(_ root: URL) throws -> (DJSystem,[Song]) { let system = DJSystem(storageURL:root),library = try XCTUnwrap(system.library) let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) diff --git a/docs/DJ_WORKSTATION.md b/docs/DJ_WORKSTATION.md index 671281b..c80139f 100644 --- a/docs/DJ_WORKSTATION.md +++ b/docs/DJ_WORKSTATION.md @@ -1,11 +1,11 @@ # DJ workstation — feature/dj_workstation -The main window has two tabs: **Karaoke** and **DJ Studio**. Both workspaces stay mounted, preserving playback, MIDI handling and selections when switching. The DJ tab has a larger independent browser. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope. +The main window has two tabs: **Karaoke** and **DJ Studio**. Playback, MIDI handling and selections persist when switching. Native tab pages hide or detach inactive drop destinations so karaoke cannot intercept DJ drops. The DJ tab has a larger independent browser. Existing singer, venue, rotation, video, background music and recursive directory import features remain available. Background music is a compact strip above karaoke transport. Each tab opens its own settings scope. ## Using the decks - Use the DJ browser’s Import Files / Folder button for DJ music. It has its own `DJ/Library.sqlite`, `DJ/Media` and playlists under the app support folder. It starts empty; existing karaoke songs are not migrated. The tested recursive importer is reused with audio-only import, without CDG graphics. MP3, WAV, M4A, MP4/M4V/MOV audio, AIFF/AIF, CAF, FLAC and AAC extensions are accepted; decoding depends on macOS format support. Imports support progress, cancellation and duplicate detection. -- Select a DJ library row and use Load A/B, use the row’s A/B buttons, or drag a DJ library song onto a deck. Loading is paused, with no brief autoplay. Finder audio files can also be dropped onto either deck. Dropping onto a playing deck asks for confirmation before importing or replacing anything; Cancel preserves playback and Replace & Load explicitly overrides protection. Library loads also respect the active-deck protection preference. +- 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. Finder audio files can also be dropped onto either deck’s header, platter area or centre waveform. Back-to-back drops are queued while a copy/import is running; re-dropping existing media still loads the selected deck. Dropping onto a playing deck asks for confirmation before importing or replacing anything; Cancel preserves playback and Replace & Load explicitly overrides protection. Library loads also respect the active-deck protection preference. - 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 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. @@ -28,7 +28,9 @@ Automix requires Internal mixer mode and starts only when at most one deck is pl ## Audio routing -DJ Settings → Devices lists CoreAudio outputs including the Mac’s speakers and the controller’s internal sound card, with automatic device-list refresh on connection changes. Choose a stereo channel pair next to each device, then Apply; Revert restores the applied configuration. Routing persists in `DJ/DJAudioRouting.json`. +DJ Settings → Devices lists routable CoreAudio outputs and every reported stereo pair, including ch 1–2 and ch 3–4 on the four-channel Numark. Explicit native channel menus apply selections immediately. Routing persists in `DJ/DJAudioRouting.json`; valid device/pair changes preserve playing/paused state and source position. Channel-only changes use a live HAL update where supported, while a physical-device switch can require a brief engine restart. Active headphone preview is re-routed rather than discarded. Conflicting intermediate edits leave the last valid route active and show an inline explanation. There is no Apply step. + +Use the named controller preset button to configure its pairs. Switching to External sets up the two deck pairs and disables cue if those pairs are both public outputs. The detected-device list shows input/output counts and all stereo pairs; input-only devices are identified separately. Device connection notifications and a refresh loop while settings are open keep the list current. - **Internal:** both decks use Main Output. Pre-Cueing can use another device or a non-overlapping pair on the same multichannel interface. On Mixtrack Go, choose Numark MixTrack Go Audio, main ch 1–2 and pre-cue ch 3–4. - **External:** decks A and B have independent device/pair selectors. The software crossfader is bypassed; use an external mixer. Decks cannot share the same device/pair, and cue cannot overlap either public output. @@ -69,3 +71,5 @@ Media imports stream into a staging file, verify SHA-256, and atomically rename The full macOS suite covers karaoke, recursive CDG/audio import, independent DJ imports/playlists, rotation, background handoff, media repair, real audio loops, live loop resizing, linked Sync, cue editing, waveform/BPM guides, Finder override, artwork, real knob double-clicks, native faders, XML profiles, automatic controller detection/reconnection, actual CoreMIDI output and delayed LED initialization/shutdown. Native workspace tab switching preserves simultaneous playback. Automix tests exercise timed transitions, both playing decks during a fade, manual takeover, loop waiting, unavailable files and queue persistence without launch autoplay. Attached-interface checks verify available output devices and AUHAL stereo channel maps when the hardware is connected; that check skips when the interface is unavailable. Physical button response, visible LEDs and audible socket isolation still require hands-on verification. Builds compile for arm64 and x86_64; tests execute on Apple silicon. Temporary builds, logs, captures and scripts are under `/private/tmp/kjmix-xml-mappings`. + +Latest validation: **145 tests executed, zero failures, one attached-interface check skipped because the user confirmed the Numark was unplugged**. Earlier attached-interface runs verified the four-channel AUHAL maps; native menu selection and live binding changes are also tested independently of hardware.