broken changes
This commit is contained in:
@@ -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<Double>?
|
||||
init(x: Double,viewport: Double,span: Double,width: Double,loop: ClosedRange<Double>?,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<Double>? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+31
-9
@@ -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<AudioDeviceID>.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<AudioDeviceID>.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()
|
||||
|
||||
Reference in New Issue
Block a user